Repository navigation
Amigos y mensajes
English · Español
«Amigos» permite que dos instalaciones de Tapete se hagan amigas, se escriban con cifrado de extremo a extremo y se manden invitaciones a una partida en red. Es la única función que habla con el servidor de Tapete. La partida nunca pasa por ese servidor: sigue siendo de equipo a equipo con iroh y el código directo (ver Juego en línea). El servidor guarda claves públicas, quién es amigo de quién y bytes de mensajes que no puede leer.
Dónde está:
| Parte | Ficheros |
|---|---|
| Lógica en Rust |
src-tauri/src/amigos/ (mod.rs, firma.rs, cifrado.rs, cliente.rs, sincronizar.rs, avisar.rs, fondo.rs, pruebas_servidor.rs, vectores/*.json) |
| Android, con la app cerrada |
src-tauri/plugins/android-extra/android/src/main/java/com/estigio/proxybuilder/extra/AvisosAmigos.kt (el Worker de WorkManager y Nativo), android::avisos_amigos, android::pedir_permiso_avisos
|
| Almacén local |
src-tauri/src/store/amigos.rs, migración src-tauri/src/store/migrations/0010_amigos.sql
|
| Comandos |
src-tauri/src/commands/amigos.rs (amigos_*), y enlace_invitacion, enlace_amigo en src-tauri/src/enlace.rs
|
| Pantalla |
src/features/amigos/ (amigos-dialog.tsx, consentimiento.tsx, lista.tsx, conversacion.tsx, seguridad.tsx, boton-amigos.tsx, enlaces.ts, visto.ts), src/stores/amigos-store.ts
|
| Servidor | Un proyecto aparte, Next.js + SurrealDB. Su contrato es su docs/api.md (API v1, rutas bajo /api/v1/). |
No se manda nada al servidor hasta que la persona abre «Amigos» y acepta. consentimiento.tsx dice qué se manda (apodo, la clave pública del equipo, plataforma y versión de la app, con quién se es amigo y cuándo se le vio, los mensajes cifrados) y qué no (el texto de los mensajes ni las invitaciones, decks, cartas, partidas, nombre real, correo, contactos), con un enlace a la política de privacidad del servidor (amigos_privacidad abre <servidor>/privacy en el navegador del sistema, porque la vista web nunca sale a internet).
Aceptar llama a amigos_activar, que escribe el ajuste amigos.activo = "1" (app_settings), arranca el bucle de sincronización y hace una primera vuelta. Cualquier otro comando que toque el servidor pasa por exigir_activo y falla con InvalidInput, motivo: amigos_apagado mientras esté apagado. Por qué: el resto de la app (decks, PDF, la mesa, incluso jugar en red) funciona sin cuenta ni servidor central; tener amigos es algo que se elige, no un requisito.
No hay cuenta ni contraseña. La identidad es la clave secreta ed25519 del nodo de iroh (AppState.red.clave(), guardada en app_data_dir/identidad/clave, sellada con el Android Keystore en Android). La misma clave:
- firma cada petición al servidor, y
- da la clave de cifrado de los mensajes.
Por qué una sola clave: exportar e importar la identidad (red_identidad_exportar/red_identidad_importar) lo lleva todo, y en Android el sellado del Keystore que ya existe protege también la clave de cifrado; no hay un segundo secreto que guardar, copiar ni sellar.
Todas las rutas salvo GET /healthz llevan cuatro cabeceras (firma.rs):
| Cabecera | Valor |
|---|---|
X-Tapete-Key |
Clave pública ed25519, 32 bytes, hex en minúsculas |
X-Tapete-Timestamp |
Segundos Unix, en decimal |
X-Tapete-Nonce |
16 bytes aleatorios, hex en minúsculas, uno nuevo por petición |
X-Tapete-Signature |
Firma ed25519 (64 bytes), hex en minúsculas |
La cadena que se firma va en UTF-8, con líneas unidas por \n y sin salto final (firma::cadena):
TAPETE-v1
<MÉTODO en mayúsculas>
<ruta?query tal cual viaja, p. ej. /api/v1/sync?since=…>
<timestamp>
<nonce>
<sha256 en hex del cuerpo crudo (de la cadena vacía si no hay cuerpo)>
El servidor acepta una hora a ±300 s (si no, clock_skew, que la pantalla explica como «ajusta la hora») y recuerda cada nonce 24 h (replayed_nonce). Si el sistema no da aleatoriedad, firmar manda igualmente la petición con el nonce a cero: es mejor que el servidor la rechace que repetir un nonce en silencio.
cifrado::secreto deriva un secreto X25519 de la semilla ed25519 con HKDF-SHA256 (sal vacía, info = "tapete/cifrado/v1", 32 bytes). Su parte pública se publica en POST /hello junto con una firma ed25519 de
TAPETE-v1-cifrado
<clave pública x25519, hex en minúsculas>
El servidor comprueba esa firma antes de guardarla, y la app la comprueba otra vez (cifrado::clave_de_amigo) antes de cifrar para un amigo o descifrar lo suyo: un servidor que cambiara la clave necesitaría la clave de identidad del amigo para firmar el cambio. Un amigo cuya clave no se verifica sale con claveValida = false y no se le puede escribir (bad_encryption_key).
Cada mensaje es un crypto_box (X25519 + XSalsa20-Poly1305, la caja de libsodium) entre mi secreto derivado y la clave X25519 del amigo, con un nonce aleatorio nuevo de 24 bytes (cifrado::cerrar). Los bytes son:
[versión = 1][nonce: 24 bytes][caja]
y viajan en base64 estándar en ciphertext. Dentro de la caja va un JSON pequeño, Carta: { "tipo": "texto", "texto": … } o { "tipo": "invitacion", "codigo": … }, y enviado (milisegundos, reloj de quien lo manda). El texto tiene un límite de 2000 caracteres (MAX_TEXTO, el mismo MAX_MENSAJE en pantalla) y el mensaje cifrado de 4096 bytes (MAX_CIFRADO, el maxMessageBytes del servidor). cifrado::abrir devuelve None con otra versión, una caja alterada o un mensaje de otra clave; el bucle lo apunta en el log y lo salta.
Dicho tal cual, como lo dice el comentario del código:
- No hay secreto hacia delante. La clave de la caja es fija para cada pareja. Quien robe la identidad de un equipo puede descifrar los mensajes viejos que haya guardado.
- El servidor ve los metadatos: quién es amigo de quién, quién escribe a quién y cuándo, el tamaño de los mensajes y la última conexión. Nunca lo que se dice.
Los mensajes se borran del servidor en cuanto el destinatario los confirma, y allí caducan a los 30 días. Un destinatario puede tener como mucho 500 mensajes sin recoger (recipient_inbox_full).
cifrado::numero_de_seguridad da a los dos lados las mismas 12 cifras (1234 5678 9012): SHA-256 de tapete/seguridad/v1\n<identidad A>\n<clave de cifrado A>\n<identidad B>\n<clave de cifrado B> con las dos parejas ordenadas por identidad, y los 8 primeros bytes como entero big-endian módulo 10¹². Si coinciden al compararlas en persona o por otro canal de confianza, nadie se ha metido en medio; la persona marca al amigo como verificado (amigos_verificar, seguridad.tsx).
La app recuerda la clave de cifrado de cada amigo tal como la vio la última vez (amigos_claves.clave_cifrado, store::amigos::anotar_clave). Si una sincronización posterior trae otra, la fila queda con cambio = 1 y pierde verificado, y el amigo enseña claveCambio hasta que la persona lo mira. Por qué: una clave que cambia es o bien una identidad importada de verdad en el equipo del amigo o bien un ataque; solo la persona puede saberlo, así que la app lo marca en vez de decidir.
sincronizar::arrancar lanza una sola tarea en segundo plano (la guarda en_marcha); sincronizar::sincronizar es una vuelta, de una en una por Amigos.turno (el servidor admite solo 3 peticiones a la vez por clave).
En cada vuelta:
-
POST /hellosolo si cambió el apodo, la versión de la app, la clave de cifrado o la dirección del servidor desde la última (una huella guardada enamigos.hola) o si aún no hay código de amigo. La respuesta da el código de amigo (TAP-XXXX-XXXX, enamigos.codigo). El apodo es el nombre del equipo de jugar en red (pasar::nombre_equipo, hasta 32 caracteres,Tapetesi está vacío). - Reintentar los míos que siguen
pendiente(escritos sin red), con el mismoclientIdpara que el servidor no los duplique. -
GET /sync?since=<cursor>: amigos (con sus claves,online= visto hace menos de 150 s,lastSeen), solicitudes recibidas y enviadas, bloqueados, avisos del servidor, mensajes nuevos y acuses de entrega. Cada mensaje se verifica, se descifra y se guarda, y luego se confirman todos conPOST /messages/ack(el servidor los borra). Si el servidor dicemoreMessages, la vuelta vuelve a pedir, hasta 10 páginas. - Mandar el estado entero a la pantalla (
amigos://estado) y, si alguien escribió,amigos://mensajescon sus claves y un aviso del sistema.
Ritmo:
| Situación | Siguiente vuelta |
|---|---|
| Normal | 60 s (NORMAL) |
Con una conversación abierta (amigos_ritmo(true)) |
10 s (CON_CHAT), y una vuelta en el momento de abrirla |
App en segundo plano (ciclo.rs llama a segundo_plano(true)) |
Ninguna llamada; el bucle solo espera. Al volver, una vuelta |
| Fallo del servidor o de la red | 60 s, 120 s, 240 s, 480 s y luego 600 s (MAX_ESPERA) en cada fallo siguiente; el error se ve en el estado y se limpia con la siguiente vuelta buena |
Acciones como mandar, aceptar una solicitud o abrir el diálogo llaman a Amigos::ya() para despertar el bucle en vez de esperar.
sequenceDiagram
participant A as App A (Rust)
participant S as Servidor de Tapete
participant B as App B (Rust)
Note over A: amigos_mandar(clave, texto)
A->>A: comprueba la firma de la clave de B, crypto_box, guarda «pendiente»
A->>S: POST /messages {to, ciphertext, clientId} (firmada)
S-->>A: 201 {id}
A->>A: marca «enviado»
loop cada 60 s (10 s con un chat abierto)
B->>S: GET /sync?since=cursor (firmada)
S-->>B: amigos, solicitudes, mensajes, acuses, cursor
B->>B: comprueba la clave de A, abre la caja, guarda, emite amigos://mensajes
B->>S: POST /messages/ack {ids}
Note over S: mensajes borrados, acuse apuntado para A
end
A->>S: GET /sync (siguiente vuelta)
S-->>A: deliveries [clientId]
A->>A: marca «entregado»
Estados de un mensaje (amigos_mensajes.estado): los míos pasan de pendiente a enviado y a entregado, o a fallido si el servidor dice que no (no sois amigos, bandeja llena…; un fallo de red los deja pendiente). amigos_reintentar vuelve a mandar uno fallido con el mismo clientId. Los recibidos son recibido; el índice único de id_servidor evita duplicados si se perdió un ack.
avisar::mensajes_nuevos saca un aviso del sistema solo si la ventana principal no tiene el foco, y su texto es solo «Mensaje de » o « te invita a una partida», nunca el texto del mensaje. Rust no escribe prosa: la pantalla le pasa las plantillas traducidas con amigos_textos al arrancar y al cambiar de idioma (TextosAvisosAmigos en amigos-dialog.tsx), y Rust solo cambia {apodo}. Sin esos textos no se enseña nada.
En Android el bucle de sync se para con la app en segundo plano, así que ese camino solo salta en el escritorio (ventana abierta pero sin el foco).
sequenceDiagram
participant W as WorkManager
participant K as AvisosAmigos.kt
participant R as amigos::fondo (Rust, JNI)
participant S as Servidor de Tapete
W->>K: cada ~15 min, con red
K->>K: ¿app delante? ¿sin permiso? → nada
K->>K: lee identidad/clave o abre clave.sellada (Cofre)
K->>R: Nativo.comprobar(datos, clave)
R->>S: GET /sync (sin cursor, sin ack)
S-->>R: mensajes pendientes + amigos
R->>R: descifra solo para distinguir texto e invitación; quita los ids de avisados.json
R-->>K: [{ amigo, titulo, cuerpo }]
K->>K: un aviso por amigo
-
Solo mira.
fondo::comprobarllama aGET /syncsinsincey nunca mandaackni toca la base de datos. Los mensajes se quedan en el servidor hasta que la app se abre y su bucle los recoge como siempre, así que un aviso de aquí nunca pierde ni duplica un mensaje en la conversación. Descifra solo para saber si lo último nuevo de cada amigo es un mensaje o una invitación; el texto del aviso es el mismo «Mensaje de » / « te invita a una partida», nunca el mensaje. -
Textos. En Android,
amigos_textostambién escribe las plantillas enapp_data_dir/amigos/textos-avisos.json(fondo::guardar_textos): elWorkerno tiene pantalla. Sin ese fichero no se enseña nada. -
Sin repetir.
app_data_dir/amigos/avisados.jsonguarda los ids ya avisados que siguen en el servidor (fondo::avisos_dese queda solo con esos, así la lista nunca crece más que lo pendiente). Un mensaje que nadie recoge no avisa otra vez cada 15 minutos. -
La clave. El
Workerlee la identidad como la eligered::identidad::cargar_o_crear: la deidentidad/clavesi está en claro y, si no,identidad/clave.selladaabierta con la misma clave del Keystore (objetoCofredeAndroidExtraPlugin.kt, aliasproxy-builder-identidad, compartido concofreSellar/cofreAbrir). 🛑 Nunca escribe, renombra ni borra los ficheros de la identidad: sellar lo que esté en claro lo hace la app al arrancar. Los 32 bytes van a Rust por JNI y se ponen a cero en los dos lados después. -
Programarlo.
sincronizar::arrancar(con amigos activo) llama aandroid::avisos_amigos(Some(app_data_dir)), que le da la carpeta de datos a Kotlin (SharedPreferences) y encola el trabajo periódico únicotapete-avisos-amigosconKEEP(programarlo otra vez no lo duplica);amigos_desactivarlo cancela (None). Se pasa la carpeta en vez de adivinarla para que valgan también los perfiles dePB_PERFIL. -
Permiso. Android 13+ pide
POST_NOTIFICATIONS.amigos_activarlo pide (android::pedir_permiso_avisos, solo si aún está «por preguntar») cuando la persona acepta usar el servidor, no antes. Sin él, o con los avisos apagados, elWorkerno hace nada. -
Con la app delante, nada. Si el proceso de la app está en primer plano el
Workervuelve enseguida: la app abierta ya sincroniza cada 60 s. - Canal
default(el mismo quetauri-plugin-notification, así los dos comparten los ajustes del sistema), icono pequeño de un colorres/drawable/aviso_tapete.xmlen el plugin, un aviso por amigo (id = hash de la clave: el siguiente sustituye al anterior), y tocarlo abre la app.consumer-rules.proprotegeNativoyAvisosAmigosde R8: Rust exportaJava_com_estigio_proxybuilder_extra_Nativo_comprobar(cratejni, dependencia solo de Android, la misma versión que ya usa tao).
Por qué WorkManager y no un servicio de push: el push (FCM) necesitaría Google Play Services y un proyecto de Firebase, y daría a un tercero un token de cada dispositivo. Una comprobación cada 15 minutos es lo mínimo que Android permite a un trabajo periódico y basta para mensajes entre amigos; cuesta una petición firmada pequeña cuando hay red y nada si no.
Deducido: Android puede retrasar más el trabajo con el ahorro de batería o en reposo (Doze), y en las versiones de algunos fabricantes quitar la app de «Recientes» cuenta como forzar su cierre (WorkManager lo vuelve a programar al siguiente arranque).
En una conversación, mientras esta app es anfitriona de una partida en red que aún no ha empezado (la sala de espera), «Invitar a tu partida» manda un mensaje de tipo invitacion con el código directo de la partida. amigos_invitar pasa el código por red::codigo::solo_rele, que deja solo la dirección del relé y quita las IP del anfitrión (y falla con motivo: sin_rele si no hay relé). Por qué: ni el servidor ni el equipo del amigo deben saber las direcciones de red del anfitrión; con el relé basta para conectar, e iroh pasa solo a un camino directo. «Unirse», en quien la recibe, pone el código en «Unirse con un código» (useAmigos.pedirUnirse); unirse sigue siendo otro toque.
| Enlace | Para qué |
|---|---|
https://tapete.zodhub.app/friend/<TAP-…> (enlace_amigo) |
Se comparte para que te añadan de amigo. El QR de mi código (amigos_qr) lleva este enlace, así que lo abre cualquier cámara de móvil |
https://tapete.zodhub.app/join/<código> (enlace_invitacion) |
Se comparte para invitar a una partida; se hace con solo_rele, así que sin IP. Lo usan «Copiar enlace» y «Compartir» del diálogo de jugar en red |
tapete://amigo/<TAP-…> |
Enlace que abre «Amigos» con el código en «Añadir» |
tapete://unirse/<código> (y proxybuilder://unirse/…) |
Enlace que abre «Jugar en red» con el código puesto |
Los enlaces que se comparten son los de la web porque valen para todos: la web abre la app con el esquema que entienda la versión instalada o lleva a descargarla. Los tapete:// son los que usa la web (o el sistema) para abrir la app. Un enlace nunca hace nada por sí solo: solo rellena un campo. «Añadir» acepta el código suelto, el enlace de la web o uno tapete://amigo/ (enlace::codigo_amigo_pegado, que además corrige las letras de Crockford que se confunden, como O→0); en el móvil puede leer el QR con la cámara (lib/escaner.ts). El campo de unirse (features/mesa/red/codigo.ts) acepta también el enlace /join/ de la web.
La última conexión se enseña como tiempo relativo en el idioma de la interfaz (visto.ts, Intl.RelativeTimeFormat: «hace 5 minutos»).
Lo secundario de un amigo (número de seguridad, quitar, bloquear, denunciar) es un desplegable en escritorio y una hoja que sube en el teléfono (MenuAmigo en conversacion.tsx), como los demás menús del móvil. El diálogo tiene alto fijo solo con la lista o una conversación (para que no salte de una a otra); la pantalla del consentimiento toma el alto de su texto. Con el teléfono de lado y el teclado abierto, el título del diálogo se esconde para que la conversación tenga los ~150 dp que quedan (véase Móvil).
Importar otra identidad (red_identidad_importar) convierte este equipo en otra persona para el servidor: otra clave, otro código, otros amigos y otro cursor. sincronizar::otra_clave espera a la vuelta que esté en marcha, vacía amigos.codigo, amigos.cursor y amigos.hola, olvida la lista de amigos en memoria y provoca un hello nuevo. Las conversaciones guardadas se quedan en el disco.
amigos_desactivar(borrarServidor, borrarLocal) (la parte de salir al final de lista.tsx):
-
borrarServidor: primeroDELETE /me; el servidor borra la clave con sus amistades, bloqueos, mensajes y acuses (not_registeredse da por hecho). Se vacían el código, el cursor y la huella de aquí. -
borrarLocal:store::amigos::borrar_todoborra las conversaciones y lo sabido de las claves en este equipo. - Siempre:
amigos.activo = "0", y el bucle se para la próxima vez que despierta.
Quitar o bloquear a un amigo (amigos_quitar, amigos_bloquear) olvida también esa conversación aquí (store::amigos::olvidar). El bloqueado no se entera: para él tu código «no existe». amigos_denunciar manda una denuncia con motivo y una nota opcional, nunca mensajes. amigos_nuevo_codigo cambia el código de amigo; el viejo deja de valer.
Migración 0010_amigos.sql:
-
amigos_mensajes: la conversación descifrada (amigo= identidad del amigo en hex,mio,tipo,texto,enviado= reloj de quien lo escribió,orden= reloj de este equipo, que ordena la conversación,estado,id_cliente,id_servidor). -
amigos_claves: de cada amigo, la última clave de cifrado vista,verificado,cambioyleido_hasta(los sin leer).
Ajustes en app_settings: amigos.activo, amigos.cursor, amigos.hola, amigos.codigo.
Los errores del servidor ({ code, params }) llegan a la pantalla como ErrorCode::Server con params.code = el código del servidor; src/lib/errores.ts tiene una frase para cada uno (clock_skew, friend_code_not_found, not_friends, recipient_inbox_full, app_outdated, maintenance…). Los fallos de red van por error::red/error::estado_http como en todo cliente HTTP, y el cliente sale de tls.rs.
cliente::PRODUCCION es https://tapete.zodhub.app. En depuración, PB_TAPETE la cambia (por ejemplo, un servidor en local en el puerto 1530), leída al arrancar o, como una app de Android nunca ve las variables de quien la lanza, al compilar (PB_TAPETE=http://10.0.2.2:1530 pnpm tauri android dev llega desde el emulador al servidor del equipo).
-
firma.rsycifrado.rscomprueban los vectores compartidossrc-tauri/src/amigos/vectores/firma-v1.jsonyclave-cifrado-v1.json. Son copia exacta de lostests/vectores/del servidor: si un lado los cambia, hay que cambiarlos en los dos, o la app y el servidor dejan de entenderse. -
pruebas_servidor.rs(#[ignore]) lleva dos identidades nuevas contra un servidor de verdad: alta, amistad, un mensaje y una invitación cifrados, confirmación y acuse de entrega. Se lanza conPB_TAPETE=http://127.0.0.1:1530 cargo test --lib amigos::pruebas_servidor -- --ignored. -
pruebas_servidor.rscomprueba también el camino en segundo plano contra el servidor de verdad: un aviso con el apodo, ninguno en la vuelta siguiente y el mensaje aún pendiente para la app. -
fondo.rscomprueba un aviso por amigo sin el texto, que no se repite, que lo recogido por la app sale de la lista, que lo que no se descifra se ignora y que sin los textos de la pantalla no se avisa. -
avisar.rscomprueba que un aviso lleva el apodo y nunca el texto;src/features/amigos/__tests__/visto.test.tscubre el tiempo relativo.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
- Rename-to-Tapete · Cambio de nombre a Tapete
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- The-Card-Stamp · El sello de las cartas
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
- Game-Engine · Motor de la mesa
- Game-Table · Mesa de juego
- Online-Play · Juego en línea
- Friends-and-Messages · Amigos y mensajes
Mobile · Móvil
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Card-Search · Búsqueda de cartas
- Set-Browser · Explorador de sets
Reference · Referencia