Skip to content

Friends and Messages

Pablo Martínez López edited this page Nov 10, 2026 · 4 revisions

Friends and Messages

English · Español

«Amigos» lets two Tapete installations become friends, write to each other with end-to-end encryption and send each other invitations to an online game. It is the only feature that talks to the Tapete server. The game itself never goes through that server: it stays peer to peer over iroh with the direct code (see Online Play). The server stores public keys, who is friends with whom, and message bytes it cannot read.

Where it lives:

Part Files
Rust logic src-tauri/src/amigos/ (mod.rs, firma.rs, cifrado.rs, cliente.rs, sincronizar.rs, avisar.rs, fondo.rs, pruebas_servidor.rs, vectores/*.json)
Android, app closed src-tauri/plugins/android-extra/android/src/main/java/com/estigio/proxybuilder/extra/AvisosAmigos.kt (WorkManager Worker and Nativo), android::avisos_amigos, android::pedir_permiso_avisos
Local storage src-tauri/src/store/amigos.rs, migration src-tauri/src/store/migrations/0010_amigos.sql
Commands src-tauri/src/commands/amigos.rs (amigos_*), plus enlace_invitacion, enlace_amigo in src-tauri/src/enlace.rs
Screen 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
Server A separate Next.js + SurrealDB project. Its contract is its docs/api.md (API v1, routes under /api/v1/).

Consent first

Nothing is sent to the server until the person opens «Amigos» and accepts. consentimiento.tsx lists what is sent (nickname, the device's public key, platform and app version, the friendship graph and «last seen», the encrypted messages) and what is not (message text, invitations in clear, decks, cards, games, real name, e-mail, contacts), with a link to the server's privacy policy (amigos_privacidad opens <server>/privacy in the system browser, because the webview never goes online).

Accepting calls amigos_activar, which writes the setting amigos.activo = "1" (app_settings), starts the sync loop and runs a first sync. Every other command that touches the server checks exigir_activo and fails with InvalidInput, motivo: amigos_apagado while it is off. Why: the rest of the app (decks, PDF, the game table, even online play) works with no account and no central server; a friends list is opt-in, not a precondition.

Identity: one key for everything

There is no account and no password. The identity is the ed25519 secret key of the iroh node (AppState.red.clave(), stored in app_data_dir/identidad/clave, sealed with the Android Keystore on Android). The same key:

  • signs every request to the server, and
  • derives the encryption key for messages.

Why one key: exporting and importing the identity (red_identidad_exportar/red_identidad_importar) carries everything, and on Android the existing Keystore sealing already protects the encryption key; there is no second secret to store, back up or seal.

Signed requests

Every route except GET /healthz carries four headers (firma.rs):

Header Value
X-Tapete-Key ed25519 public key, 32 bytes, lowercase hex
X-Tapete-Timestamp Unix seconds, decimal
X-Tapete-Nonce 16 random bytes, lowercase hex, new for each request
X-Tapete-Signature ed25519 signature (64 bytes), lowercase hex

The signed string is UTF-8, lines joined by \n, with no trailing newline (firma::cadena):

TAPETE-v1
<METHOD in upper case>
<path?query exactly as it travels, e.g. /api/v1/sync?since=…>
<timestamp>
<nonce>
<sha256 hex of the raw body (of the empty string when there is no body)>

The server accepts a timestamp within ±300 s (clock_skew otherwise, which the screen explains as «set the clock right») and remembers each nonce for 24 h (replayed_nonce). If the system has no randomness, firmar still sends the request with a zero nonce: a server refusal is better than silently reusing a nonce.

The encryption key

cifrado::secreto derives an X25519 secret from the ed25519 seed with HKDF-SHA256 (empty salt, info = "tapete/cifrado/v1", 32 bytes). Its public half is published in POST /hello together with an ed25519 signature over

TAPETE-v1-cifrado
<x25519 public key, lowercase hex>

The server checks that signature before storing it, and the app checks it again (cifrado::clave_de_amigo) before encrypting to a friend or decrypting from them: a server that swapped the key would need the friend's identity key to sign the swap. A friend whose key does not verify is shown with claveValida = false and cannot be written to (bad_encryption_key).

Messages on the wire

Each message is crypto_box (X25519 + XSalsa20-Poly1305, libsodium's box) between my derived secret and the friend's X25519 key, with a fresh random 24-byte nonce (cifrado::cerrar). The bytes are:

[version = 1][nonce: 24 bytes][box]

sent as standard base64 in ciphertext. Inside the box is a small JSON Carta: { "tipo": "texto", "texto": … } or { "tipo": "invitacion", "codigo": … }, plus enviado (milliseconds, sender's clock). Text is limited to 2000 characters (MAX_TEXTO, the same MAX_MENSAJE on screen) and the encrypted message to 4096 bytes (MAX_CIFRADO, the server's maxMessageBytes). cifrado::abrir returns None for a wrong version, a tampered box or a message from another key; the sync loop logs and skips it.

What it does not protect

Stated plainly, as the code comment does:

  • No forward secrecy. The box key is static per pair. Whoever steals a device's identity can decrypt any old message they recorded.
  • Metadata is visible to the server: who is friends with whom, who writes to whom and when, message sizes, and «last seen». Never what is said.

Messages are deleted from the server as soon as the recipient acknowledges them, and expire there after 30 days. A recipient can have at most 500 uncollected messages (recipient_inbox_full).

Safety number and key changes

cifrado::numero_de_seguridad gives both sides the same 12 digits (1234 5678 9012): SHA-256 of tapete/seguridad/v1\n<identity A>\n<encryption key A>\n<identity B>\n<encryption key B> with the two pairs sorted by identity, first 8 bytes as a big-endian integer modulo 10¹². If the numbers match when compared in person or over another trusted channel, nobody is in the middle; the person marks the friend as verified (amigos_verificar, seguridad.tsx).

The app remembers each friend's encryption key as last seen (amigos_claves.clave_cifrado, store::amigos::anotar_clave). If a later sync brings a different key, the row gets cambio = 1 and loses verificado, and the friend shows claveCambio until the person looks at it. Why: a changed key is either a legitimate identity import on the friend's side or an attack; only the person can tell, so the app flags it instead of deciding.

The sync loop

sincronizar::arrancar runs one background task (guarded by en_marcha); sincronizar::sincronizar is one round, serialized by Amigos.turno (the server allows only 3 concurrent requests per key).

Each round:

  1. POST /hello only if the nickname, the app version, the encryption key or the server base URL changed since the last one (a fingerprint stored as amigos.hola) or there is no friend code yet. The answer gives the friend code (TAP-XXXX-XXXX, stored as amigos.codigo). The nickname is the device name used for online play (pasar::nombre_equipo, up to 32 characters, Tapete if empty).
  2. Retry my messages still pendiente (sent while offline), with the same clientId so the server does not duplicate them.
  3. GET /sync?since=<cursor>: friends (with their keys, online = seen under 150 s, lastSeen), requests in and out, blocked keys, server notices, new messages and delivery receipts. Each message is verified, decrypted and stored, then all ids are acknowledged with POST /messages/ack (the server deletes them). If the server says moreMessages, the round asks again, up to 10 pages.
  4. Publish the whole state to the screen (amigos://estado) and, if anyone wrote, amigos://mensajes with their keys and a system notification.

Timing:

Situation Next round
Something changed on the server (cambio over /events, see below) Right away
Normal 60 s (NORMAL)
A conversation is open (amigos_ritmo(true)) and /events is not connected 10 s (CON_CHAT), and one round right away when it opens
App in the background (ciclo.rs calls segundo_plano(true)) No calls at all; the loop just waits. Coming back triggers a round
Server or network failure 60 s, 120 s, 240 s, 480 s, then 600 s (MAX_ESPERA) for every further failure; the error is shown in the state and cleared by the next good round

Actions such as sending, accepting a request or opening the dialog call Amigos::ya() to wake the loop immediately instead of waiting.

sequenceDiagram
    participant A as App A (Rust)
    participant S as Tapete server
    participant B as App B (Rust)
    Note over A: amigos_mandar(clave, texto)
    A->>A: verify B's key signature, crypto_box, store as «pendiente»
    A->>S: POST /messages {to, ciphertext, clientId} (signed)
    S-->>A: 201 {id}
    A->>A: mark «enviado»
    S-->>B: event: cambio (over /events)
    loop right away, and every 60 s as a safety net
        B->>S: GET /sync?since=cursor (signed)
        S-->>B: friends, requests, messages, receipts, cursor
        B->>B: verify A's key, open box, store, emit amigos://mensajes
        B->>S: POST /messages/ack {ids}
        Note over S: messages deleted, receipt recorded for A
    end
    A->>S: GET /sync (next round)
    S-->>A: deliveries [clientId]
    A->>A: mark «entregado»
Loading

Message states (amigos_mensajes.estado): mine go pendiente → enviado → entregado, or fallido when the server refuses (not friends, inbox full…; network errors leave them pendiente). amigos_reintentar resends a failed one with the same clientId. Received ones are recibido; the unique index on id_servidor stops duplicates when an ack was lost.

Real time: /events (SSE)

While the app is in front it keeps one connection open to the server: GET /events, a Server-Sent Events stream (amigos/directo.rs). It is signed like every other request (the signature only opens it) and carries no data of its own:

Event What the app does
hola (on connect) Wakes the sync loop to catch up (Amigos::ya())
cambio Wakes the sync loop: something of mine changed
escribiendo {from} Emits amigos://escribiendo with that friend's key
: latido comment every 20 s Nothing; it keeps proxies (Cloudflare cuts after 100 s of silence) and the app's 60 s read timeout happy

The server sends cambio on a new message, a delivery receipt, a friend request (new, accepted, declined or withdrawn), a friend removing or blocking me or deleting their account, a friend's hello (new nickname or encryption key), and a friend connecting (so they show «online» at once: online is also true while their /events is open). Why no data in the event: everything still goes through sync, with its signatures, permissions and encryption; the stream only says when to look. That keeps the server's push path trivial and the app's logic in one place.

Details:

  • One reader (LectorSse, in cliente.rs) cuts the stream into events, tolerating chunks split anywhere and \r\n.
  • en_directo is true while connected: then an open chat no longer speeds the loop up to 10 s. The 60 s round stays as a safety net (and refreshes «last seen»).
  • Reconnection: 2 s, doubling up to 2 min; a connection that lasted over a minute resets the wait. A 404 that is not a JSON error from the API (an old server without the route) stops the listener, and only the periodic sync remains. A 404 with {code} (for example not_registered, when the stream races the first hello) is just retried.
  • Amigos.cortar (notify_waiters, so no stale notice cuts the next connection) closes it when the app goes to the background on a phone, when friends are switched off and when the identity changes; the connection also checks on every chunk that it should still be open (app in front, friends on, same key).
  • On the server the listeners live in the process memory (lib/dominio/eventos.ts); the server runs as a single PM2 process. A client not connected gets nothing and finds out in its next sync.

Typing

While I write in a conversation, amigos_escribiendo(clave) sends POST /typing {to} at most every 3 s (only with text in the field; sending a message resets the timer). The server forwards it as escribiendo only if we are friends with no block, and stores nothing; its answer is the same either way. The screen keeps «Escribiendo…» for 6 s after the last notice (ESCRIBIENDO_MS, useEscribiendo in amigos-store.ts), and clears it as soon as a message from that friend arrives. It shows under the name in the conversation and in the friend's row of the list.

My name, and a friend's name and note

  • My name («Tu nombre», top of the list, TuNombre in nombres.tsx) is the same device name used to pass decks (pasar_nombre/pasar_nombre_set, setting red.apodo; empty = the device's own name). Saving it wakes the loop; the hello fingerprint changed, so the next round re-introduces itself and the server tells my friends at once.
  • A friend's name and note («Nombre y nota» in the friend's ⋯ menu, NombreYNota): a name only I see (up to 32 characters, alias) and a private note (up to 500, nota), like the friend notes of some online games. Stored only on this device (amigos_alias, migration 0011) through amigos_personal(clave, alias, nota); empty clears each. The friend never sees them and the server does not know them. The name I gave is shown first everywhere (nombreAmigo: list, conversation, report dialog, system notifications), with their own name underneath; the note shows as a dashed bar at the top of the conversation (tap to edit) and as an icon in the list. Removing or blocking the friend, or leaving friends with local deletion, forgets them too.

Notifications

avisar::mensajes_nuevos shows a system notification only when the main window does not have focus (on Android pulling down the notification shade is enough), and its body is only «Message from » or « invites you to a game», never the message text. Rust writes no prose: the screen sends the translated templates with amigos_textos at startup and when the language changes (TextosAvisosAmigos in amigos-dialog.tsx), and Rust only replaces {apodo}. Without those texts nothing is shown.

On Android the sync loop stops while the app is in the background, so that path only fires on desktop (window open but not focused).

With the app closed (Android)

sequenceDiagram
    participant W as WorkManager
    participant K as AvisosAmigos.kt
    participant R as amigos::fondo (Rust, JNI)
    participant S as Tapete server
    W->>K: every ~15 min, with network
    K->>K: app in front? no permission? → stop
    K->>K: read identidad/clave or unseal clave.sellada (Cofre)
    K->>R: Nativo.comprobar(datos, clave)
    R->>S: GET /sync (no cursor, no ack)
    S-->>R: pending messages + friends
    R->>R: decrypt only to tell text from invitation; drop ids already in avisados.json
    R-->>K: [{ amigo, titulo, cuerpo }]
    K->>K: one notification per friend
Loading
  • It only looks. fondo::comprobar calls GET /sync without since and never sends ack nor touches the database. The messages stay on the server until the app opens and its own loop collects them as always, so a notification from here can never lose or duplicate a message in the conversation. It decrypts only to know whether the last new one from each friend is a message or an invitation; the body is the same «Message from » / « invites you to a game», never the text.
  • Texts. On Android amigos_textos also writes the templates to app_data_dir/amigos/textos-avisos.json (fondo::guardar_textos): the worker has no screen. Without that file nothing is shown.
  • No repeats. app_data_dir/amigos/avisados.json keeps the ids already notified that are still on the server (fondo::avisos_de keeps only those, so the list never grows beyond what is pending). A message nobody collected does not notify again every 15 minutes.
  • The key. The worker reads the identity like red::identidad::cargar_o_crear chooses it: the clear identidad/clave if present, otherwise identidad/clave.sellada opened with the same Keystore key (Cofre object in AndroidExtraPlugin.kt, alias proxy-builder-identidad, shared with cofreSellar/cofreAbrir). 🛑 It never writes, renames or deletes identity files: sealing what is in clear is the app's job at start-up. The 32 bytes go to Rust through JNI and are zeroed on both sides afterwards.
  • Scheduling. sincronizar::arrancar (when friends are on) calls android::avisos_amigos(Some(app_data_dir)), which hands the data folder to Kotlin (SharedPreferences) and enqueues the unique periodic work tapete-avisos-amigos with KEEP (scheduling it again does not duplicate it); amigos_desactivar cancels it (None). The folder is passed rather than guessed so that PB_PERFIL profiles work too.
  • Permission. Android 13+ needs POST_NOTIFICATIONS. amigos_activar asks for it (android::pedir_permiso_avisos, only while the state is still «prompt») at the moment the person accepts using the server, not before. Without it, or with notifications off, the worker does nothing.
  • Skipped when in front. If the app process is in the foreground the worker returns at once: the open app already syncs every 60 s.
  • One look for every friends notification on Android. AvisosAmigos.avisar (companion, also reachable from Rust as android::aviso_amigo → avisoAmigo in the plugin) is used both by the worker and by avisar.rs with the app open: tauri-plugin-notification would show its generic «ⓘ» icon and, on layers such as MIUI that show the notification expanded, an empty body. Ours sets BigTextStyle so the text is there expanded too, CATEGORY_MESSAGE and the green accent. On desktop the plugin is still used.
  • Channel default (the same as tauri-plugin-notification, so both share the system settings), small monochrome icon res/drawable/aviso_tapete.xml in the plugin, one notification per friend (id = hash of the key, the next replaces the previous), tapping opens the app. consumer-rules.pro keeps Nativo and AvisosAmigos from R8: Rust exports Java_com_estigio_proxybuilder_extra_Nativo_comprobar (the jni crate, Android-only dependency, same version tao already uses).

Why WorkManager and not a push service: push (FCM) would need Google Play Services and a Firebase project, and would give a third party a token for every device. A 15-minute check is the minimum Android allows for periodic work and is enough for messages between friends; it costs one small signed request with network available and nothing otherwise.

Inferred: Android may delay the work further in battery saver or Doze, and on some manufacturers' builds a swiped-away app is treated as force-stopped (WorkManager then reschedules at the next start).

Invitations to a game

Inviting works like on Steam: no code to copy.

  • From the waiting room (host only), «Invitar amigos» (features/mesa/red/invitar-amigos.tsx) lists every friend whose key is valid, online ones first, each with «Invitar» (then «Invitado»).
  • From a conversation, «Invitar a una partida» is always there unless this app is already in a started game or is a guest in someone else's. With a waiting room of mine open it sends the invitation right away («Invitar a tu partida»). Without one it sets useAmigos.invitarA, closes «Amigos» and goes to «Jugar»: the entry opens «Jugar en red», which creates the game by itself with the deck chosen there as soon as it is ready, and InvitarAmigos sends the invitation once the code exists (it reads and clears invitarA from the store in one step, so an effect that runs twice does not send it twice). With no deck chosen, a notice asks for one.

Either way the message is of type invitacion with the game's direct code. amigos_invitar passes the code through red::codigo::solo_rele, which keeps only the relay address and drops the host's IP addresses (and fails with motivo: sin_rele if there is no relay). Why: the server and the friend's device should not learn the host's network addresses; the relay is enough to connect and iroh upgrades to a direct path on its own. The recipient's «Unirse» (useAmigos.pedirUnirse) opens «Jugar en red» and joins at once as a player with the deck and list chosen in «Jugar»: pressing «Unirse» on the invitation is already the person's own decision, so no second tap is asked for. Without a chosen deck the code stays in «Unirse con un código» with a notice to pick one.

Links and the QR

Link Use
https://tapete.zodhub.app/friend/<TAP-…> (enlace_amigo) Shared to be added as a friend. The QR of my own code (amigos_qr) encodes this link, so any phone camera opens it
https://tapete.zodhub.app/join/<code> (enlace_invitacion) Shared to invite to a game; built with solo_rele, so without IPs. «Copiar enlace» and «Compartir» in the online-play dialog use it
tapete://amigo/<TAP-…> Deep link that opens «Amigos» with the code in «Añadir»
tapete://unirse/<code> (and proxybuilder://unirse/…) Deep link that opens «Jugar en red» with the code filled in

The shared links are the web ones because they work for everyone: the website opens the app with whichever scheme the installed version understands, or sends the person to download it. The tapete:// links are what the website (or the OS) uses to open the app. A deep link never acts on its own: it only fills a field. «Añadir» accepts a bare code, a web link or a tapete://amigo/ link (enlace::codigo_amigo_pegado, which also normalises Crockford look-alikes such as O→0); on mobile it can scan the QR with the camera (lib/escaner.ts). The join field (features/mesa/red/codigo.ts) also accepts the web /join/ link.

«Last seen» is shown as relative time in the interface language (visto.ts, Intl.RelativeTimeFormat: «5 minutes ago»).

A friend's secondary actions (safety number, remove, block, report) are a dropdown on desktop and a sheet that slides up on a phone (MenuAmigo in conversacion.tsx), like every other phone menu. The dialog has a fixed height only with the list or a conversation (so it does not jump between them); the consent screen takes the height of its text. On a phone on its side with the keyboard open, the dialog title hides itself so the conversation keeps the ~150 dp that remain (see Mobile).

Changing identity

Importing another identity (red_identidad_importar) makes this device a different person for the server: different key, code, friends and cursor. sincronizar::otra_clave waits for any running round, clears amigos.codigo, amigos.cursor and amigos.hola, forgets the cached friend list, and triggers a new hello. Stored conversations stay on disk.

Leaving

amigos_desactivar(borrarServidor, borrarLocal) (the «leave» section at the bottom of lista.tsx):

  • borrarServidor: DELETE /me first; the server deletes the key with its friendships, blocks, messages and receipts (not_registered is accepted as already done). The local code, cursor and fingerprint are cleared.
  • borrarLocal: store::amigos::borrar_todo deletes the conversations and key records on this device.
  • Always: amigos.activo = "0", and the loop stops at its next wake-up.

Removing or blocking a friend (amigos_quitar, amigos_bloquear) also forgets that conversation locally (store::amigos::olvidar). A block is invisible to the blocked person: for them your code «does not exist». amigos_denunciar sends a report with a reason and an optional note, never messages. amigos_nuevo_codigo replaces the friend code; the old one stops working.

Local storage

Migration 0010_amigos.sql:

  • amigos_mensajes: the decrypted conversation (amigo = friend's identity hex, mio, tipo, texto, enviado = sender's clock, orden = this device's clock, which orders the conversation, estado, id_cliente, id_servidor).
  • amigos_claves: per friend, the last seen encryption key, verificado, cambio and leido_hasta (unread counts).

Migration 0011_amigos_alias.sql: amigos_alias (amigo, alias, nota), what I note about a friend, only for me. A separate table because an amigos_claves row is born with the friend's encryption key, and these can be set before; a row with neither is deleted.

Settings in app_settings: amigos.activo, amigos.cursor, amigos.hola, amigos.codigo.

Errors

Server errors ({ code, params }) reach the screen as ErrorCode::Server with params.code set to the server's code; src/lib/errores.ts has a sentence for each (clock_skew, friend_code_not_found, not_friends, recipient_inbox_full, app_outdated, maintenance…). Network failures go through error::red/error::estado_http like every other HTTP client, and the client comes from tls.rs.

Server address

cliente::PRODUCCION is https://tapete.zodhub.app. In debug builds PB_TAPETE overrides it (for example a local server on port 1530), read at start-up or, since an Android app never sees the variables of whoever launches it, at compile time (PB_TAPETE=http://10.0.2.2:1530 pnpm tauri android dev reaches the host's server from the emulator).

Tests

  • firma.rs and cifrado.rs check the shared vectors src-tauri/src/amigos/vectores/firma-v1.json and clave-cifrado-v1.json. They are an exact copy of the server's tests/vectores/: if either side changes them, both must change, or the app and the server stop understanding each other.
  • pruebas_servidor.rs (#[ignore]) runs two fresh identities against a real server: register, befriend, exchange an encrypted message and an invitation, acknowledge, and see the delivery receipt. Run with PB_TAPETE=http://127.0.0.1:1530 cargo test --lib amigos::pruebas_servidor -- --ignored.
  • pruebas_servidor.rs also checks the background path against the real server: one notification with the nickname, none on the next round, and the message still pending for the app.
  • fondo.rs checks one notification per friend without the text, no repeats, ids collected by the app dropped, undecryptable messages ignored, and nothing without the screen's texts.
  • cliente.rs reads an SSE stream split anywhere; store/amigos.rs sets, clears and forgets a friend's name and note; fondo.rs uses the name I gave in the notification.
  • On the server, lib/dominio/eventos.test.ts (who gets each notice) and tests/api/directo.test.ts (request, acceptance, message and typing arrive over /events; typing only from a friend; online while connected).
  • avisar.rs checks that a notification carries the nickname and never the text; src/features/amigos/__tests__/visto.test.ts covers the relative time.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally