// 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 // 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} { sal, iteraciones, clave_acceso, dek_contrasena } */ export async function prepararNuevaContrasena(dekBruta, nuevaContrasena, iteraciones) { const sal = aBase64(aleatorio(16)); const claves = await derivarClaves(normalizarContrasena(nuevaContrasena), sal, iteraciones); return { sal, iteraciones, clave_acceso: claves.claveAcceso, dek_contrasena: await envolverDEK(claves.claveEnvoltura, dekBruta), }; } /* ===================================================================== * DOCUMENTOS * ===================================================================== */ /** * Cifra un documento completo (título + texto) en una sola "caja". * El ID del documento se mezcla como dato asociado: si alguien moviera * esta caja a otro documento en la base de datos, no se podría abrir. * * @param {CryptoKey} dek * @param {string} id ID del documento (32 hex). * @param {{titulo: string, cuerpo: string}} documento * @returns {Promise} Base64( IV | datos cifrados ) */ export async function cifrarDocumento(dek, id, documento) { const iv = aleatorio(12); // NUEVO en cada guardado: imprescindible en AES-GCM const json = JSON.stringify({ v: 1, titulo: documento.titulo, cuerpo: documento.cuerpo }); const cifrado = new Uint8Array(await sutil.encrypt( { name: 'AES-GCM', iv, additionalData: codificador.encode(PREFIJO_DOCUMENTO + id) }, dek, codificador.encode(json) )); return aBase64(unir(iv, cifrado)); } /** * Abre una "caja" de documento. * * @param {CryptoKey} dek * @param {string} id * @param {string} contenidoBase64 * @returns {Promise<{titulo: string, cuerpo: string}>} * @throws Si la clave no es la correcta o los datos han sido alterados. */ export async function descifrarDocumento(dek, id, contenidoBase64) { const bytes = deBase64(contenidoBase64); const claro = await sutil.decrypt( { name: 'AES-GCM', iv: bytes.slice(0, 12), additionalData: codificador.encode(PREFIJO_DOCUMENTO + id) }, dek, bytes.slice(12) ); const datos = JSON.parse(decodificador.decode(claro)); return { titulo: String(datos.titulo || ''), cuerpo: String(datos.cuerpo || '') }; } CAJANEGRA_FIN_CRIPTO_JS; } // **************************************************************************** // 230 · ARCHIVO: api.js (comunicación con el servidor) // **************************************************************************** function bdn_caja_archivo_api_js() { return <<<'CAJANEGRA_FIN_API_JS' /** * ===================================================================== * CAJA NEGRA · COMUNICACIÓN CON EL SERVIDOR * --------------------------------------------------------------------- * Una sola función, llamar(), para hablar con api/index.php. * Todo viaja como JSON por HTTPS. Lo que contiene datos íntimos ya va * cifrado desde cripto.js: el servidor solo ve "cajas cerradas". * ===================================================================== */ /** Dirección de la API (relativa: siempre en nuestra propia web). */ const DIRECCION_API = 'api/'; /** * Error devuelto por el servidor, con su código (p. ej. "usuario_ocupado") * y los datos extra que traiga (p. ej. minutos de bloqueo). */ export class ErrorApi extends Error { /** * @param {string} codigo * @param {object} [datos] */ constructor(codigo, datos = {}) { super(codigo); this.name = 'ErrorApi'; this.codigo = codigo; this.datos = datos; } } /** * Pide una acción al servidor. * * @param {string} accion Nombre de la acción (ver la lista en api/index.php). * @param {object} [datos] Datos de la acción. * @returns {Promise} La respuesta del servidor (sin el campo "ok"). * @throws {ErrorApi} Si el servidor responde con error o no hay conexión. */ export async function llamar(accion, datos = {}) { let respuesta; try { respuesta = await fetch(DIRECCION_API, { method: 'POST', credentials: 'same-origin', // envía la cookie de sesión, solo a nuestra web cache: 'no-store', headers: { 'Content-Type': 'application/json', // Cabecera que exige el servidor: demuestra que la petición // sale de nuestra propia página (protección anti-CSRF). 'X-CajaNegra': '1', }, body: JSON.stringify({ accion, ...datos }), }); } catch { // Sin internet o servidor caído. throw new ErrorApi('sin_conexion'); } let json; try { json = await respuesta.json(); } catch { throw new ErrorApi('error_servidor'); } if (!json || json.ok !== true) { const { ok, error, ...resto } = json || {}; throw new ErrorApi(error || 'error_servidor', resto); } const { ok, ...resultado } = json; return resultado; } CAJANEGRA_FIN_API_JS; } // **************************************************************************** // 240 · ARCHIVO: app.js (pantallas y lógica) // **************************************************************************** function bdn_caja_archivo_app_js() { return <<<'CAJANEGRA_FIN_APP_JS' /** * ===================================================================== * CAJA NEGRA · LÓGICA DE LA APLICACIÓN * --------------------------------------------------------------------- * Une las piezas: * · textos-es.js → lo que se lee en pantalla. * · cripto.js → cifrar y descifrar (todo en el navegador). * · api.js → hablar con el servidor. * * ÍNDICE DEL ARCHIVO * 1. Estado (lo que la app recuerda mientras está abierta) * 2. Utilidades de pantalla (mostrar, errores, avisos, diálogos) * 3. Fechas y descargas * 4. Medidor de contraseña * 5. Entrar (con o sin doble factor) * 6. Darse de alta + clave de recuperación * 7. Recuperar la cuenta * 8. Documentos: cargar y descifrar * 9. Índice y buscador * 10. Editor con guardado automático * 11. Ajustes: exportar, contraseña, doble factor, baja * 12. Salir y cierre por inactividad * 13. Arranque * * PRINCIPIO DE SEGURIDAD: los textos solo existen descifrados en la * memoria de esta pestaña. Al salir, la página se RECARGA para borrar * todo rastro de la memoria. Nunca se guarda nada en el navegador * (ni localStorage, ni cookies, ni caché). * ===================================================================== */ import { t, IDIOMA, TEXTOS } from './textos-es.js'; import * as cripto from './cripto.js'; import { llamar, ErrorApi } from './api.js'; import qrcode from './vendor/qrcode.mjs'; /* ===================================================================== * 1. ESTADO * ===================================================================== */ /** Minutos sin tocar nada tras los que se cierra el diario. */ const MINUTOS_INACTIVIDAD = 15; /** Espera tras dejar de escribir antes de guardar (milisegundos). */ const ESPERA_GUARDADO = 1200; /** Mismas reglas que el servidor para el nombre de usuario. */ const PATRON_USUARIO = /^[a-z0-9._-]{3,32}$/; /** Longitud mínima de la contraseña. */ const CONTRASENA_MINIMA = 10; /** * Todo lo que la app recuerda mientras la pestaña está abierta. * NADA de esto se guarda en ningún sitio: al recargar, desaparece. */ const estado = { usuario: null, // nombre de usuario con sesión abierta dek: null, // clave maestra (CryptoKey NO extraíble) sal: null, // sal de la contraseña (para cambiarla, etc.) iteraciones: null, // vueltas de PBKDF2 de esta cuenta dekEnvuelta: null, // clave maestra envuelta con la contraseña actual dosfa: false, // ¿tiene doble factor? documentos: new Map(), // id → { id, titulo, cuerpo, creado, modificado, danado? } actual: null, // documento abierto en el editor pendiente: null, // datos entre el paso 1 y 2 del login con doble factor claveRecuperacion: '', // solo durante la pantalla de la clave, tras el alta codigosEmergencia: [], // solo mientras se muestran, tras activar el 2FA ultimaActividad: Date.now(), }; /** Atajo para buscar un elemento por su id. */ const $ = (id) => document.getElementById(id); /* ===================================================================== * 2. UTILIDADES DE PANTALLA * ===================================================================== */ /** * Pone todos los textos de la interfaz a partir de textos-es.js, * usando los atributos data-t, data-t-placeholder, data-t-aria y data-t-title. */ function aplicarTextos() { document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = t(el.dataset.t); }); document.querySelectorAll('[data-t-placeholder]').forEach((el) => { el.placeholder = t(el.dataset.tPlaceholder); }); document.querySelectorAll('[data-t-aria]').forEach((el) => { el.setAttribute('aria-label', t(el.dataset.tAria)); }); document.querySelectorAll('[data-t-title]').forEach((el) => { el.title = t(el.dataset.tTitle); }); document.documentElement.lang = IDIOMA.slice(0, 2); } /** * Muestra una pantalla (una
) y oculta las demás. * @param {string} id */ function mostrar(id) { document.querySelectorAll('.pantalla').forEach((p) => { p.hidden = p.id !== id; }); window.scrollTo(0, 0); } /** * Muestra u oculta la capa "Abriendo tu diario…" mientras se calculan * las claves (puede tardar un segundo en móviles). * @param {string|null} texto Texto a mostrar, o null para ocultarla. */ function cargando(texto) { $('carga').hidden = !texto; if (texto) $('carga-texto').textContent = texto; } /** * Aviso breve en la parte inferior de la pantalla, que desaparece solo. * @param {string} texto */ let temporizadorAviso = null; function aviso(texto) { const caja = $('aviso'); caja.textContent = texto; caja.hidden = false; clearTimeout(temporizadorAviso); temporizadorAviso = setTimeout(() => { caja.hidden = true; }, 4500); } /** * Convierte cualquier error en un texto comprensible para el usuario. * @param {unknown} error * @returns {string} */ function textoError(error) { if (error instanceof ErrorApi) { const clave = 'err_' + error.codigo; return clave in TEXTOS ? t(clave, error.datos) : t('err_generico'); } if (error instanceof Error && error.message.startsWith('err_')) { return t(error.message); } console.error(error); // para depurar; nunca contiene textos del diario return t('err_generico'); } /** * Muestra (o borra) el mensaje de error de un formulario. * @param {HTMLFormElement} formulario * @param {string} [texto] Sin texto, lo oculta. */ function errorFormulario(formulario, texto = '') { const caja = formulario.querySelector('.formulario__error'); caja.textContent = texto; caja.hidden = !texto; } /** * Ventana de confirmación propia (más clara que la del navegador). * @param {string} texto Pregunta. * @param {string} textoBoton Texto del botón de confirmar. * @returns {Promise} true si confirma. */ function confirmar(texto, textoBoton) { const dialogo = $('dialogo'); $('dialogo-texto').textContent = texto; $('dialogo-si').textContent = textoBoton; return new Promise((resolver) => { const cerrar = (respuesta) => { $('dialogo-si').onclick = null; $('dialogo-no').onclick = null; dialogo.oncancel = null; dialogo.close(); resolver(respuesta); }; $('dialogo-si').onclick = () => cerrar(true); $('dialogo-no').onclick = () => cerrar(false); dialogo.oncancel = (e) => { e.preventDefault(); cerrar(false); }; // tecla Esc dialogo.showModal(); }); } /** * Desactiva los botones de un formulario mientras trabaja, para evitar * dobles envíos. * @param {HTMLFormElement} formulario * @param {boolean} ocupado */ function ocupar(formulario, ocupado) { formulario.querySelectorAll('button').forEach((b) => { b.disabled = ocupado; }); } /** * Normaliza un nombre de usuario igual que el servidor. * @param {string} texto * @returns {string} */ function limpiarUsuario(texto) { return String(texto).trim().toLowerCase(); } /* ===================================================================== * 3. FECHAS Y DESCARGAS * ===================================================================== */ const formatoFecha = new Intl.DateTimeFormat(IDIOMA, { day: 'numeric', month: 'short', year: 'numeric' }); const formatoFechaLarga = new Intl.DateTimeFormat(IDIOMA, { day: 'numeric', month: 'long', year: 'numeric' }); const formatoFechaHora = new Intl.DateTimeFormat(IDIOMA, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', }); /** * @param {number} segundos Marca de tiempo Unix. * @param {'corta'|'larga'|'hora'} [tipo] * @returns {string} */ function fecha(segundos, tipo = 'corta') { const d = new Date(segundos * 1000); if (tipo === 'larga') return formatoFechaLarga.format(d); if (tipo === 'hora') return formatoFechaHora.format(d); return formatoFecha.format(d); } /** * Descarga un texto como archivo .txt, generado en el propio navegador * (no pasa por el servidor). * @param {string} nombre * @param {string} contenido */ function descargarTexto(nombre, contenido) { const blob = new Blob([contenido], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const enlace = document.createElement('a'); enlace.href = url; enlace.download = nombre; document.body.append(enlace); enlace.click(); enlace.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } /** * Copia un texto al portapapeles. * @param {string} texto */ async function copiar(texto) { try { await navigator.clipboard.writeText(texto); aviso(t('aviso_copiado')); } catch { // Algunos navegadores lo bloquean: el usuario puede seleccionarlo a mano. } } /** Fecha de hoy en formato AAAA-MM-DD, para nombres de archivo. */ function hoyISO() { return new Date().toISOString().slice(0, 10); } /* ===================================================================== * 4. MEDIDOR DE CONTRASEÑA * ===================================================================== */ /** * Estima la fuerza de una contraseña de 0 (no vale) a 4 (muy buena). * Premia sobre todo la LONGITUD (una frase larga es mejor que una * palabra con símbolos raros) y algo la variedad de caracteres. * * @param {string} contrasena * @returns {number} */ function fuerza(contrasena) { const c = cripto.normalizarContrasena(contrasena); const largo = [...c].length; if (largo < CONTRASENA_MINIMA || /^(.)\1+$/.test(c)) return 0; let nivel = 1; if (largo >= 14) nivel++; if (largo >= 20) nivel++; const tipos = [/[a-zñç]/, /[A-ZÑÇ]/, /\d/, /\s/, /[^\p{L}\d\s]/u].filter((r) => r.test(c)).length; if (tipos >= 3) nivel++; return Math.min(nivel, 4); } /** * Conecta el medidor a un campo de contraseña. El medidor es la * barra .medidor que está dentro de la misma etiqueta