Document wave 0b: introductions, Unlisted, contact links, anonymous reads

API.md (and its page): POST /e2e/intro, the anonymous routes, handle
certificates and their canonical bytes, contact links, GET /e2e/devices/me,
which routes stopped serving device times, and how every username door
answers an unlisted name.

CONFIGURATION.md (and its page): FN_INTRO_RATE, FN_INTRO_RATE_NETWORK,
FN_INTRO_RATE_TARGET, FN_SEND_RATE_RECIPIENT_IP, and what FN_SEND_RATE's
recipient bucket counts now. Corrected three stale defaults in the same
table: FN_BUNDLE_RATE is 600 (said 120), FN_BUNDLE_RATE_TARGET 60 (said 6),
FN_SEND_RATE 3000 (said 600).

/metadata, en and es: introductions, the Listed/Unlisted flag and its limit
(sign-up still says a name is taken), contact links, device times now shown
only to their own account, and the access-log effect of the anonymous
routes. Each says the apps do not use it yet.

METADATA-PASSES.md and IDENTIFYING-DATA.md: Pass 3 entries with the timing
measurements and the production counts read on 2026-10-07.

check-configuration-doc.py: 89 variables, all documented. check-doc-pages:
api and configuration current (the engine-sourced pages were already stale
against the local flamenet-e2e checkout and are left alone, as in wave 0a).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-07 14:37:36 -04:00
co-authored by Claude Opus 5.5
parent 4095ca60ec
commit 114fadbcbf
8 changed files with 298 additions and 31 deletions
+107 -11
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 482337e6be3ddc29 -->
source-sha256: bfd847e0ce8e8778 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="relay-api">Relay API</h1>
@@ -208,7 +208,16 @@ reads its inbox.</p>
</tr>
<tr>
<td><code>GET /e2e/devices/{user}</code></td>
<td>List a user's devices, costs no prekey</td>
<td>List a user's devices, costs no prekey. Registration and last-poll
times (<code>created_at</code>, <code>last_seen</code>) only when
<code>{user}</code> is the caller's own account, kept there because both
apps' Devices screens read <code>created_at</code> from it</td>
</tr>
<tr>
<td><code>GET /e2e/devices/me</code></td>
<td>The caller's own devices, with <code>created_at</code> and
<code>last_seen</code>. Where those times belong; the line above keeps
them only until the apps have moved here</td>
</tr>
<tr>
<td><code>DELETE /e2e/devices/{device_id}</code></td>
@@ -224,9 +233,11 @@ reads its inbox.</p>
</tr>
<tr>
<td><code>POST /e2e/messages</code></td>
<td>Deposit envelopes. With <code>FN_PAD_ENFORCE=enforce</code>, a
request carrying any payload not exactly 16384 or 65536 characters long
is refused with <code>unpadded</code></td>
<td>Deposit envelopes. A sealed <code>delivery_token</code> may be one
the recipient issued or one from <code>POST /e2e/intro</code>. With
<code>FN_PAD_ENFORCE=enforce</code>, a request carrying any payload not
exactly 16384 or 65536 characters long is refused with
<code>unpadded</code></td>
</tr>
<tr>
<td><code>GET /e2e/messages</code></td>
@@ -249,6 +260,18 @@ digests</td>
<td>Add and drop single-use delivery capabilities (§21.4)</td>
</tr>
<tr>
<td><code>GET /e2e/handle-cert</code></td>
<td>A certificate for the caller's own handle, see below</td>
</tr>
<tr>
<td><code>POST /e2e/contact-links</code></td>
<td><code>{"add": [hash], "revoke": [hash]}</code>, each a base64
<code>SHA-256</code> of a contact-link secret. At most 5 per account; a
sixth is refused with <code>409</code> rather than dropped. The relay
keeps the hash and the account, no time and no record of use. Answers
<code>{"ok": true, "links": n}</code></td>
</tr>
<tr>
<td><code>POST /e2e/attachments</code></td>
<td>Upload one encrypted blob</td>
</tr>
@@ -353,6 +376,71 @@ devices its owner did not add (relay#("#")32)</td>
</tr>
</tbody>
</table>
<p><strong>Asking without logging in.</strong> These read no credential
at all, and carry the account being asked about in the body, never the
URL, so it reaches no access log. Every meter that is about an account
is keyed by a keyed hash of it, never the id. The relay still sees the
address.</p>
<table>
<thead>
<tr>
<th>Route</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>POST /e2e/intro</code></td>
<td><code>{"handle": h}</code> (listed accounts only) or
<code>{"link": secret}</code> (base64 or base64url, 32 bytes). Returns
<code>user_id</code>, <code>fnid</code>, three <code>intro_tokens</code>
good for one sealed send each for 10 minutes (<code>expires_in</code>),
the target's bundles (<code>devices</code>, one-time prekeys popped) and
<code>handle_cert</code>. An unlisted or unknown handle, an unknown or
revoked link and an account with no devices all get the same
<code>404 not_found</code> "No such account.", no sooner than 5 ms.
Metered per address (<code>FN_INTRO_RATE</code>), per network
(<code>FN_INTRO_RATE_NETWORK</code>), and once an account is found, per
account (<code>FN_INTRO_RATE_TARGET</code>) and per network and account
(a third of that); at most 90 unspent intro tokens per account</td>
</tr>
<tr>
<td><code>POST /e2e/anon/presence</code></td>
<td><code>{"tokens": [...]}</code>, at most 64. The same answer as
<code>POST /messenger/presence</code>. Metered per address, by token
count</td>
</tr>
<tr>
<td><code>POST /e2e/anon/devices</code></td>
<td><code>{"user_id": n, "cap": token?}</code>. Identity keys only, no
<code>created_at</code> or <code>last_seen</code></td>
</tr>
<tr>
<td><code>POST /e2e/anon/bundles</code></td>
<td><code>{"user_id": n, "cap": token?}</code>. As
<code>GET /e2e/keys/{user}</code>, popping a prekey per device</td>
</tr>
</tbody>
</table>
<p>For the last two, <code>cap</code> is a presence token the subject
issued to the caller. When it is one of the subject's, the token is the
meter; otherwise, or without one, the address is (and for bundles, the
address and account together, the shape <code>GET /e2e/keys</code> uses
per caller). A <code>cap</code> that does not match is ignored, not
refused, so the route says nothing about whether a token is good.</p>
<p><strong>Handle certificates</strong> state that <code>user_id</code>
holds <code>handle</code> on this relay until <code>exp</code>:</p>
<pre class="json"><code>{ &quot;v&quot;: 1, &quot;audience&quot;: &quot;relay.example&quot;, &quot;user_id&quot;: 7, &quot;handle&quot;: &quot;ada&quot;,
&quot;exp&quot;: 1760000000, &quot;sig&quot;: &quot;&lt;base64 Ed25519&gt;&quot; }</code></pre>
<p><code>sig</code> is over the bytes
<code>fnhandle-v1|&lt;audience&gt;|&lt;user_id&gt;|&lt;handle&gt;|&lt;exp&gt;</code>,
made with the key that signs the log head, whose public half is the
descriptor's <code>issuerPublicKey</code> that both apps pin on first
sight. Handles are lowercase letters and digits, so <code>|</code>
cannot occur inside one. Good for 7 days. A relay with no signing key
answers <code>GET /e2e/handle-cert</code> with
<code>503 handle_cert_unsigned</code> and puts <code>null</code> in an
introduction.</p>
<p><strong>Addresses and remote relays.</strong> The first two answer
without a credential, because a first message needs them before any
session exists. Both answer yes or no for a name, which makes them a
@@ -369,7 +457,8 @@ directory somebody can walk, so both are metered per address.</p>
<tr>
<td><code>GET /e2e/account/identity/{handle}</code></td>
<td>none, metered</td>
<td>Resolve a handle to an address</td>
<td>Resolve a handle to an address. An unlisted handle answers
<code>404</code> exactly as an unknown one</td>
</tr>
<tr>
<td><code>GET /e2e/account/homes/{fnid}</code></td>
@@ -496,24 +585,30 @@ records what went with it).</p>
<tr>
<td><code>PUT /me</code>, <code>POST /me</code></td>
<td>Basic</td>
<td>Replace profile fields. <code>PUT</code> is the honest verb;
<td>Replace profile fields: <code>name</code>, <code>password</code>,
and <code>discoverable</code> (<code>false</code> is Unlisted).
<code>GET</code> and <code>PUT</code> answer with
<code>discoverable</code>. <code>PUT</code> is the honest verb;
<code>POST</code> is kept for clients that only speak it</td>
</tr>
<tr>
<td><code>GET /users</code></td>
<td>Basic</td>
<td>Exact-username lookup. It refuses without a <code>slug</code>, so it
is not a directory</td>
is not a directory. An unlisted name is <code>[]</code>, as an unknown
one, except your own</td>
</tr>
<tr>
<td><code>GET /users/{id}</code></td>
<td>Basic</td>
<td>One user's public profile</td>
<td>One user's public profile. An unlisted account is <code>404</code>,
as an unknown id, except to itself</td>
</tr>
<tr>
<td><code>GET /buddies</code>, <code>POST /buddies</code></td>
<td>Basic</td>
<td>The contact list, read and add</td>
<td>The contact list, read and add. Adding an unlisted name is
<code>404</code>, as an unknown one</td>
</tr>
<tr>
<td><code>DELETE /buddies/{id}</code></td>
@@ -601,7 +696,8 @@ the generated documentation pages.</p>
<tr>
<td><code>GET /e2e/federation/devices/{fnid}</code></td>
<td><code>fnfed-v1</code></td>
<td>List that account's devices</td>
<td>List that account's devices, identity keys only (no
<code>created_at</code> or <code>last_seen</code> since 2026-10-07)</td>
</tr>
</tbody>
</table>
+48 -6
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: f1fda7f9c788576a -->
source-sha256: 2e6507a842e6c013 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -376,16 +376,19 @@ relays.</p>
<tbody>
<tr>
<td><code>FN_BUNDLE_RATE</code></td>
<td><code>120</code>/hr</td>
<td><code>600</code>/hr</td>
<td>Prekey-bundle fetches per requester. Each pops a one-time prekey per
device, so an unmetered version is a pool drain.</td>
</tr>
<tr>
<td><code>FN_BUNDLE_RATE_TARGET</code></td>
<td><code>6</code>/hr</td>
<td><code>60</code>/hr</td>
<td>Per requester-target pair. Deliberately <strong>not</strong> metered
per target alone, that would let one abuser make a popular account
unstartable for everyone.</td>
unstartable for everyone. The anonymous
<code>POST /e2e/anon/bundles</code> uses the same two numbers per
address and per address-target pair, or this one per presence token when
the caller presents one.</td>
</tr>
<tr>
<td><code>FN_SIGNUP_MAX_PER_HOUR</code></td>
@@ -453,8 +456,47 @@ stops.</td>
</tr>
<tr>
<td><code>FN_SEND_RATE</code></td>
<td><code>600</code>/hr</td>
<td>Envelopes, per sender or per recipient when sealed.</td>
<td><code>3000</code>/hr</td>
<td>Envelopes per sender for an identified send. For a sealed send,
envelopes per address, and the recipient-wide bucket, which since
2026-10-07 counts only <strong>refused</strong> sealed attempts (one per
request): it exists to stop guessing, and when it counted accepted
envelopes too, anybody holding one working delivery key could fill it
and nobody else could reach that person for the hour. Once it is full,
sealed sends to that person are refused for the rest of the hour
whatever they carry, so a guess cannot be checked.</td>
</tr>
<tr>
<td><code>FN_SEND_RATE_RECIPIENT_IP</code></td>
<td><code>FN_SEND_RATE</code> / 10</td>
<td>Sealed envelopes from one address to one recipient. What bounds a
sender with a working key now that accepted sends no longer fill the
recipient-wide bucket, and what stops one address filling that bucket
with refusals on its own. Keyed by a keyed hash of the recipient, never
the account id.</td>
</tr>
<tr>
<td><code>FN_INTRO_RATE</code></td>
<td><code>30</code>/hr</td>
<td>Introductions (<code>POST /e2e/intro</code>) per address. Charged
before the handle or link is looked up, so a listed name, an unlisted
one and an unknown one cost the same.</td>
</tr>
<tr>
<td><code>FN_INTRO_RATE_NETWORK</code></td>
<td><code>240</code>/hr</td>
<td>Introductions per network, a /24 for IPv4 and a /48 for IPv6, the
sign-up buckets.</td>
</tr>
<tr>
<td><code>FN_INTRO_RATE_TARGET</code></td>
<td><code>30</code>/hr</td>
<td>Introductions per account, and a third of it per network and
account, so one network cannot spend an account's whole hour. A crowd of
networks still can: an account being flooded with introductions stops
getting new ones for the hour, and the answer for that is Unlisted plus
contact links. Separately, at most 90 unspent intro tokens (30
introductions) per account at once.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_RATE</code></td>
+13 -1
View File
@@ -32,6 +32,7 @@
<tr><td>Cuenta y dispositivo del remitente</td><td>Solo en envíos identificados; un envío <strong>sellado</strong> no guarda ninguno de los dos</td><td>#if(retention.deleteOnAck):en cuanto tu aplicación confirma la recepción, o #(retention.deliveredDays) día(s) después de entregarse si nunca la confirma#else:#(retention.deliveredDays) día(s) después de entregarse#endif</td></tr>
<tr><td>El texto cifrado</td><td>Es el mensaje; el servidor no tiene ninguna clave que lo abra</td><td>#(retention.undeliveredDays) días si nunca se recoge</td></tr>
<tr><td>Adjuntos (fotos, notas de voz, historias, todo cifrado)</td><td>Los dispositivos los descargan aparte del sobre que los referencia</td><td>#(retention.attachmentDays) días</td></tr>
<tr><td>Presentaciones</td><td>Cuando alguien que todavía no tiene forma de escribirte te busca por tu nombre de usuario, o abre uno de tus enlaces de contacto, el servidor le da tres pases, cada uno válido para un mensaje sellado a ti. Guarda el hash de cada pase y a qué cuenta da acceso. No anota quién preguntó, desde dónde ni cuándo. Las aplicaciones todavía no lo usan.</td><td>Cuando se usa el pase, o 10 minutos después de crearlo</td></tr>
<tr><td><strong>&ldquo;Este es urgente&rdquo;</strong>, una marca en el sobre, <strong>no se guarda</strong></td><td>Una llamada tiene que sonar mientras todavía está sonando, así que el remitente le dice al servidor que envíe la notificación con prioridad alta en vez de esperar a que la aplicación consulte. En la práctica, eso significa que el servidor distingue una llamada de un mensaje: no quién llama, ni sobre qué, pero sí que este es urgente. Es lo más informativo que un remitente decide revelar, y es una decisión: nada obliga a poner la marca.</td><td>Nunca se escribe. Se lee para decidir la notificación y se va con la petición.</td></tr>
<tr><td><strong>&ldquo;Este no necesita atención&rdquo;</strong>, la misma forma, <strong>no se guarda</strong></td><td>La otra cara de la misma moneda: el remitente dice que un sobre es mantenimiento y no algo por lo que interrumpir a una persona, así que el servidor envía un despertar en segundo plano en vez de una alerta. Eso le dice más o menos lo mismo que la marca de arriba, al revés. La alternativa, no enviar nada y dejar que la aplicación decida tras descifrar, significa depender de notificaciones silenciosas que iOS limita, así que el trato honesto es una pequeña revelación a cambio de notificaciones que sí llegan.</td><td>Nunca se escribe, igual que arriba.</td></tr>
<tr><td>Contadores del límite de registros (por IP)</td><td>Las cuentas nuevas están limitadas por dirección y por hora, y un contador necesita la dirección</td><td>una hora; el contador solo vive su ventana</td></tr>
@@ -52,7 +53,9 @@
<tr><td>Con qué versión de la aplicación consultó por última vez cada dispositivo</td><td>La versión comercial y nada más: &ldquo;0.25.0&rdquo;, nunca un número de compilación, un modelo de dispositivo ni un sistema operativo. Se sobrescribe en vez de acumularse, así que no hay registro de qué actualizaste ni cuándo. Responde a una pregunta que no se puede responder de otra forma: si todas las aplicaciones que todavía hablan con este servidor son lo bastante nuevas como para quitar algo del servidor. Lo que se quiere quitar es la lista de contactos de arriba. Quitarla mientras aplicaciones viejas todavía leen sus contactos de aquí vaciaría esas listas, así que la elección está entre esta columna y no hacer nunca el cambio. Preferimos guardar una cadena de versión a guardar tu grafo de contactos.</td></tr>
<tr><td>Resumen de la clave de entrega, uno por dispositivo</td><td>Solo el hash. La clave en sí nunca llega al servidor salvo dentro de un envío sellado. Cada uno de tus dispositivos tiene su propia clave, así que el servidor guarda un hash por dispositivo y acepta cualquiera de ellos; antes guardaba uno por cuenta, y cuando publicaba un segundo dispositivo sustituía al del primero. Quitar un dispositivo quita su hash.</td></tr>
<tr><td>Fichas de presencia, por dispositivo</td><td>Por cada contacto al que un dispositivo deja ver cuándo estás conectado, el hash de una ficha aleatoria que ese dispositivo le dio por el canal cifrado. El servidor no puede saber qué hash corresponde a quién, así que lo que guarda es a cuántos contactos ha dado una cada dispositivo. Quitar un dispositivo quita sus hashes. Una aplicación más antigua publica un solo conjunto para toda la cuenta; un dispositivo que publica el suyo sustituye ese conjunto.</td></tr>
<tr><td>Marca de última actividad por dispositivo</td><td>Se redondea a la hora antes de escribirse. Existe para que un dispositivo que lleva #(retention.deviceExpiryDays) días en silencio pueda caducar, sus claves retirarse y su correo en cola borrarse, en vez de seguir siendo localizable para siempre. El dispositivo más reciente de una cuenta nunca caduca, y una cuenta tiene como máximo #(retention.maxDevices) dispositivos.</td></tr>
<tr><td>Si la gente puede encontrarte por tu nombre de usuario</td><td>Un sí o un no, y sí salvo que lo cambies. Desactivado (No listado), cualquier forma de buscar una cuenta por su nombre de usuario, o de preguntar de quién es un número de cuenta, responde igual que para un nombre que no tiene nadie: la misma respuesta, tras la misma espera. La gente con la que ya hablas sigue llegando a ti; alguien nuevo necesita uno de tus enlaces de contacto. Los nombres de usuario son únicos, así que intentar registrarse con el tuyo sigue diciendo que está ocupado. No listado significa que nadie puede encontrarte ni añadirte por tu nombre de usuario. No convierte tu nombre de usuario en un secreto. Las aplicaciones todavía no ofrecen este ajuste.</td></tr>
<tr><td>Enlaces de contacto</td><td>Hasta cinco enlaces que permiten a alguien empezar una conversación contigo sin conocer tu nombre de usuario. El servidor guarda el hash de cada enlace y a qué cuenta lleva. No guarda cuándo lo creaste, quién lo ha usado ni cuántas veces. Revocar un enlace lo borra.</td></tr>
<tr><td>Marca de última actividad por dispositivo</td><td>Se redondea a la hora antes de escribirse. Existe para que un dispositivo que lleva #(retention.deviceExpiryDays) días en silencio pueda caducar, sus claves retirarse y su correo en cola borrarse, en vez de seguir siendo localizable para siempre. El dispositivo más reciente de una cuenta nunca caduca, y una cuenta tiene como máximo #(retention.maxDevices) dispositivos. Solo tu propia cuenta la ve, junto con cuándo se registró cada uno de tus dispositivos. Antes, cualquier miembro que preguntara por tu cuenta veía las dos cosas, lo que le decía cuándo usaste el teléfono por última vez.</td></tr>
<tr><td>Denuncias que nos envías</td><td>Se guardan #(retention.reportDays) días y luego se borran. Una denuncia nombra la cuenta denunciada y la que denuncia, el motivo elegido, y <strong>la copia del mensaje que decidiste adjuntar, en forma legible</strong>. Tu teléfono puede leer ese mensaje aunque el servidor no, y la casilla que ofrece adjuntarlo viene marcada por defecto, así que este es el único sitio donde existe aquí texto legible de un mensaje. Desmárcala y la denuncia no lleva copia. Ver <a href="/es/safety">denunciar a alguien</a>.</td></tr>
<tr><td>Intentos de llamada</td><td>Se guardan 7 días. Cuando el servidor intenta hacer sonar un teléfono para una llamada entrante, anota qué dispositivo intentó y si Apple aceptó la notificación, para que una llamada que nunca llega se pueda diagnosticar en vez de adivinar. Sin contenido, y nada sobre quién llamaba.</td></tr>
<tr><td>Estado por conversación</td><td>Hasta dónde has leído cada una de tus conversaciones, para que un hilo no vuelva a aparecer sin leer en otro de tus dispositivos. Sin contenido. No guarda qué conversaciones marcas con estrella o archivas: antes se guardaban aquí y nada las leía nunca, así que el servidor dejó de aceptarlas y borró las que tenía. Eso pertenece a tus dispositivos.</td></tr>
@@ -87,6 +90,7 @@
<li><strong>Cuándo alguien entró en un grupo.</strong></li>
<li><strong>Quién invitó a quién.</strong> Un código de invitación usado guarda cuándo se usó y nada sobre ninguna de las dos personas.</li>
<li><strong>Qué conversaciones marcas con estrella o archivas.</strong></li>
<li><strong>Quién pidió que se le presentara a ti, o usó tu enlace de contacto.</strong></li>
</ul>
<h2>Sin IA, en ninguna parte</h2>
@@ -172,6 +176,14 @@
que funciona en este dominio, el registro de accesos del proxy de entrada se borra cada
semana: las direcciones de conexión en la puerta viven como mucho siete días.
</p>
<p>
Ahora algunas preguntas se pueden hacer sin iniciar sesión, con la cuenta por la que
se pregunta dentro de la petición y no en su dirección: qué dispositivos tiene una
cuenta, lo necesario para empezar una conversación con ella, si los contactos están
conectados, y las presentaciones. Sus líneas en el registro de accesos no nombran a
nadie. La dirección IP de la conexión se sigue viendo, y suele ser la misma desde la
que tu aplicación inició sesión unos segundos antes. Las aplicaciones todavía no las usan.
</p>
<p>
Ese registro también anota <strong>el tamaño de cada petición</strong>, que es la misma
revelación que la fila de arriba entrando por una segunda puerta, y conviene decir
+13 -1
View File
@@ -33,6 +33,7 @@
<tr><td>Sender account and device</td><td>Identified sends only, a <strong>sealed</strong> send stores neither</td><td>#if(retention.deleteOnAck):the moment your app confirms receipt, or #(retention.deliveredDays) day(s) after delivery if it never does#else:#(retention.deliveredDays) day(s) once delivered#endif</td></tr>
<tr><td>The ciphertext</td><td>It is the message; the server holds no key that opens it</td><td>#(retention.undeliveredDays) days if never collected</td></tr>
<tr><td>Attachment blobs (photos, voice notes, stories, all ciphertext)</td><td>Devices fetch them separately from the envelope that references them</td><td>#(retention.attachmentDays) days</td></tr>
<tr><td>Introductions</td><td>When somebody with no way to reach you yet looks you up by your username, or opens one of your contact links, the server hands them three passes, each good for one sealed message to you. It keeps the hash of each pass and which account it opens. It does not record who asked, from where, or when. The apps do not use this yet.</td><td>When the pass is used, or 10 minutes after it was made</td></tr>
<tr><td><strong>&ldquo;This one is urgent&rdquo;</strong>, a flag on the envelope, <strong>not stored</strong></td><td>A call has to ring while it is still ringing, so the sender tells the server to push at high priority instead of waiting for the app to poll. In practice that means the server can tell a call from a message, not who is calling, or about what, but that this one is time-critical. It is the single most informative thing a sender chooses to reveal, and it is a choice: nothing forces the flag on.</td><td>Not stored as a value. But it leaves a trace, and saying &ldquo;never written down&rdquo; was wrong: every row in <strong>Ring attempts</strong> below is written only for an urgent envelope, so a row existing for your device is the flag. That is 7 days, with the device and the time, and no content.</td></tr>
<tr><td><strong>&ldquo;This one needs no attention&rdquo;</strong>, the same shape, <strong>not stored</strong></td><td>The other side of the same coin: the sender says an envelope is housekeeping rather than something a person should be interrupted for, so the server sends a background wake instead of an alert. That tells it roughly as much as the flag above does, inverted. The alternative, sending nothing and letting the app decide after it decrypts, means relying on silent pushes that iOS throttles, so the honest trade is a small disclosure for notifications that actually arrive.</td><td>Never written down, and unlike the flag above this one really does leave no trace: it selects the kind of push and nothing records that it was set.</td></tr>
<tr><td>Sign-up throttle counters (per IP)</td><td>New accounts are limited per address per hour, and a counter needs the address</td><td>one hour, the counter lives only for its window</td></tr>
@@ -53,7 +54,9 @@
<tr><td>Which app version each device last polled with</td><td>The marketing version and nothing else: &ldquo;0.25.0&rdquo;, never a build number, a device model or an operating system. Overwritten rather than appended, so there is no record of what you upgraded and when. It answers one question that cannot be answered another way: whether every app still talking to this server is new enough that something can be taken off the server. The thing being taken off is the buddy list above. Removing it while older apps still read their contacts from here would empty those contact lists, so the choice is between this column and never making the change. We would rather keep one version string than keep your contact graph.</td></tr>
<tr><td>Delivery-key digest, one per device</td><td>The hash only. The key itself never reaches the server except inside a sealed submission. Each of your devices has its own key, so the server keeps one hash per device and accepts any of them; it used to keep one per account, and a second device publishing replaced the first one&rsquo;s. Removing a device removes its hash.</td></tr>
<tr><td>Presence tokens, per device</td><td>For each contact a device lets see when you are online, the hash of a random token that device gave them over the encrypted channel. The server cannot tell which hash stands for whom, so what it holds is how many contacts each device has issued one to. Removing a device removes its hashes. An older app publishes one set for the whole account instead; a device that publishes its own set replaces that one.</td></tr>
<tr><td>Per-device last-activity stamp</td><td>Coarsened to the hour before it is written. It exists so a device that falls silent for #(retention.deviceExpiryDays) days can be expired, its keys unpublished, its queued mail deleted, rather than staying addressable for ever. An account's newest device is never expired, and an account holds at most #(retention.maxDevices) devices.</td></tr>
<tr><td>Whether people can find you by your username</td><td>One yes or no, and yes unless you change it. Turned off (Unlisted), every way of looking an account up by its username, or of asking who an account number belongs to, answers as it does for a name nobody has: the same reply, after the same wait. People already in touch with you keep reaching you; somebody new needs one of your contact links. Usernames are unique, so trying to sign up with yours still says it is taken. Unlisted means people cannot find or add you by your username. It does not make your username a secret. The apps do not offer the switch yet.</td></tr>
<tr><td>Contact links</td><td>Up to five links that let somebody start a conversation with you without knowing your username. The server keeps the hash of each link and which account it leads to. It does not keep when you made one, who has used it, or how often. Revoking a link deletes it.</td></tr>
<tr><td>Per-device last-activity stamp</td><td>Coarsened to the hour before it is written. It exists so a device that falls silent for #(retention.deviceExpiryDays) days can be expired, its keys unpublished, its queued mail deleted, rather than staying addressable for ever. An account's newest device is never expired, and an account holds at most #(retention.maxDevices) devices. Only your own account is shown it, along with when each of your devices was registered. Any member who asked about your account used to see both, which told them when you were last on your phone.</td></tr>
<tr><td>Reports you send us</td><td>Kept #(retention.reportDays) days, then deleted. A report names the account reported and the account reporting, the reason chosen, and <strong>the copy of the message you chose to attach, in readable form</strong>. Your phone can read that message even though the server cannot, and the box offering to attach it is ticked by default, so this is the one place readable message text exists here. Untick it and the report carries no copy. See <a href="/safety">reporting someone</a>.</td></tr>
<tr><td>Ring attempts</td><td>Kept 7 days. When the server tries to make a phone ring for an incoming call, it records which device it tried and whether Apple accepted the push, so a call that never arrives can be diagnosed instead of guessed at. No content, and nothing about who was calling. Worth saying plainly, because the row above it is where you would look: these are written <em>only</em> for envelopes the sender marked urgent, so the existence of a row is itself the record that the flag was set.</td></tr>
<tr><td>Per-conversation state</td><td>Which of your own conversations you have read up to, so a thread does not come back unread on another of your devices. No content. It is worth being exact about what a row here holds, because this description used not to be: a row names <strong>both</strong> accounts, yours and the other person&rsquo;s, and it records that you accepted them as a correspondent. It no longer records <em>when</em>, which it did until recently and should not have. Nor does it record which conversations you star or archive: those were stored here and never read by anything, so the server stopped taking them and cleared the ones it had. They belong on your devices.</td></tr>
@@ -87,6 +90,7 @@
<li><strong>When someone joined a group.</strong></li>
<li><strong>Who invited whom.</strong> A used invitation code keeps when it was used and nothing about either person.</li>
<li><strong>Which conversations you star or archive.</strong></li>
<li><strong>Who asked to be introduced to you, or used your contact link.</strong></li>
</ul>
<h2>No AI, anywhere</h2>
@@ -168,6 +172,14 @@
this domain, the edge proxy's access log is erased weekly, connection addresses at
the front door live for at most seven days.
</p>
<p>
Some questions can now be asked without signing in, with the account being asked
about inside the request rather than in its address: which devices an account has,
what is needed to start a conversation with it, whether contacts are online, and
introductions. Their address lines in the access log name nobody. The connection's
IP address is still seen, and it is usually the same one your app signed in from a
few seconds earlier. The apps do not use these yet.
</p>
<p>
That log also records <strong>the size of every request</strong>, which is the same
disclosure as the row above arriving by a second door, and it is worth saying plainly
+44 -8
View File
@@ -132,15 +132,18 @@ its inbox.
| Route | Purpose |
|---|---|
| `POST /e2e/devices` | Publish a device's identity and prekeys |
| `GET /e2e/devices/{user}` | List a user's devices, costs no prekey |
| `GET /e2e/devices/{user}` | List a user's devices, costs no prekey. Registration and last-poll times (`created_at`, `last_seen`) only when `{user}` is the caller's own account, kept there because both apps' Devices screens read `created_at` from it |
| `GET /e2e/devices/me` | The caller's own devices, with `created_at` and `last_seen`. Where those times belong; the line above keeps them only until the apps have moved here |
| `DELETE /e2e/devices/{device_id}` | Retire a device and stop its bundles being served |
| `GET /e2e/keys/{user}` | Fetch bundles. Pops a one-time prekey per device |
| `POST /e2e/prekeys` | Top the pool back up |
| `POST /e2e/messages` | Deposit envelopes. With `FN_PAD_ENFORCE=enforce`, a request carrying any payload not exactly 16384 or 65536 characters long is refused with `unpadded` |
| `POST /e2e/messages` | Deposit envelopes. A sealed `delivery_token` may be one the recipient issued or one from `POST /e2e/intro`. With `FN_PAD_ENFORCE=enforce`, a request carrying any payload not exactly 16384 or 65536 characters long is refused with `unpadded` |
| `GET /e2e/messages` | Poll this device's inbox. Stops near 2 MiB of payload, never below one envelope, and then adds `"more": true`: poll again from the returned `cursor` |
| `POST /e2e/push/token` | Register this device's APNs token |
| `POST /e2e/delivery-key` | Publish this device's delivery-key digest for sealed sending (§14.4). A sealed send is accepted against any of the account's device digests |
| `POST /e2e/delivery-tokens` | Add and drop single-use delivery capabilities (§21.4) |
| `GET /e2e/handle-cert` | A certificate for the caller's own handle, see below |
| `POST /e2e/contact-links` | `{"add": [hash], "revoke": [hash]}`, each a base64 `SHA-256` of a contact-link secret. At most 5 per account; a sixth is refused with `409` rather than dropped. The relay keeps the hash and the account, no time and no record of use. Answers `{"ok": true, "links": n}` |
| `POST /e2e/attachments` | Upload one encrypted blob |
| `GET /e2e/attachments/{id}` | Download it, supports `Range` |
| `POST /e2e/groups` | Create a group |
@@ -173,6 +176,39 @@ this relay's own users could read would not be one.
| `GET /e2e/log/device/{device_id}` | Every entry for a device, with its index |
| `GET /e2e/log/subject` | Every entry under one subject, so a monitor can watch an account for devices its owner did not add (relay#32) |
**Asking without logging in.** These read no credential at all, and carry
the account being asked about in the body, never the URL, so it reaches no
access log. Every meter that is about an account is keyed by a keyed hash of
it, never the id. The relay still sees the address.
| Route | Purpose |
|---|---|
| `POST /e2e/intro` | `{"handle": h}` (listed accounts only) or `{"link": secret}` (base64 or base64url, 32 bytes). Returns `user_id`, `fnid`, three `intro_tokens` good for one sealed send each for 10 minutes (`expires_in`), the target's bundles (`devices`, one-time prekeys popped) and `handle_cert`. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same `404 not_found` "No such account.", no sooner than 5 ms. Metered per address (`FN_INTRO_RATE`), per network (`FN_INTRO_RATE_NETWORK`), and once an account is found, per account (`FN_INTRO_RATE_TARGET`) and per network and account (a third of that); at most 90 unspent intro tokens per account |
| `POST /e2e/anon/presence` | `{"tokens": [...]}`, at most 64. The same answer as `POST /messenger/presence`. Metered per address, by token count |
| `POST /e2e/anon/devices` | `{"user_id": n, "cap": token?}`. Identity keys only, no `created_at` or `last_seen` |
| `POST /e2e/anon/bundles` | `{"user_id": n, "cap": token?}`. As `GET /e2e/keys/{user}`, popping a prekey per device |
For the last two, `cap` is a presence token the subject issued to the caller.
When it is one of the subject's, the token is the meter; otherwise, or without
one, the address is (and for bundles, the address and account together, the
shape `GET /e2e/keys` uses per caller). A `cap` that does not match is ignored,
not refused, so the route says nothing about whether a token is good.
**Handle certificates** state that `user_id` holds `handle` on this relay
until `exp`:
```json
{ "v": 1, "audience": "relay.example", "user_id": 7, "handle": "ada",
"exp": 1760000000, "sig": "<base64 Ed25519>" }
```
`sig` is over the bytes `fnhandle-v1|<audience>|<user_id>|<handle>|<exp>`,
made with the key that signs the log head, whose public half is the
descriptor's `issuerPublicKey` that both apps pin on first sight. Handles are
lowercase letters and digits, so `|` cannot occur inside one. Good for 7 days.
A relay with no signing key answers `GET /e2e/handle-cert` with `503
handle_cert_unsigned` and puts `null` in an introduction.
**Addresses and remote relays.** The first two answer without a credential,
because a first message needs them before any session exists. Both answer yes
or no for a name, which makes them a directory somebody can walk, so both are
@@ -180,7 +216,7 @@ metered per address.
| Route | Auth | Purpose |
|---|---|---|
| `GET /e2e/account/identity/{handle}` | none, metered | Resolve a handle to an address |
| `GET /e2e/account/identity/{handle}` | none, metered | Resolve a handle to an address. An unlisted handle answers `404` exactly as an unknown one |
| `GET /e2e/account/homes/{fnid}` | none, metered | Which relays that address publishes on |
| `POST /e2e/account/identity` | device token | Claim an address for this account |
| `POST /e2e/account/homes` | device token | Publish the relays this account can be reached on |
@@ -272,10 +308,10 @@ plaintext message transport that once lived here was removed (relay#50, and
| `POST /signup`, `GET /challenge`, `POST /signin` | none | The account routes above, in the namespace the app asks in |
| `DELETE /account` | Basic | Remove the account |
| `GET /me` | Basic | The signed-in account, also how a restored session is validated |
| `PUT /me`, `POST /me` | Basic | Replace profile fields. `PUT` is the honest verb; `POST` is kept for clients that only speak it |
| `GET /users` | Basic | Exact-username lookup. It refuses without a `slug`, so it is not a directory |
| `GET /users/{id}` | Basic | One user's public profile |
| `GET /buddies`, `POST /buddies` | Basic | The contact list, read and add |
| `PUT /me`, `POST /me` | Basic | Replace profile fields: `name`, `password`, and `discoverable` (`false` is Unlisted). `GET` and `PUT` answer with `discoverable`. `PUT` is the honest verb; `POST` is kept for clients that only speak it |
| `GET /users` | Basic | Exact-username lookup. It refuses without a `slug`, so it is not a directory. An unlisted name is `[]`, as an unknown one, except your own |
| `GET /users/{id}` | Basic | One user's public profile. An unlisted account is `404`, as an unknown id, except to itself |
| `GET /buddies`, `POST /buddies` | Basic | The contact list, read and add. Adding an unlisted name is `404`, as an unknown one |
| `DELETE /buddies/{id}` | Basic | Remove one |
| `DELETE /buddies` | Basic | Forget the whole list. No id, deliberately |
| `POST /roster/tags` | Basic | Publish the contact list as opaque tokens (§8.9c). With `device_id` (one of this account's devices) it replaces that device's set; without, the account-wide set an older app publishes |
@@ -304,7 +340,7 @@ Off by default. When `FN_FEDERATION` is not `closed`:
|---|---|---|
| `POST /e2e/federation/deposit` | `fnfed-v1` | Accept envelopes from a peer relay |
| `GET /e2e/federation/bundles/{fnid}` | `fnfed-v1` | Serve bundles for a local account |
| `GET /e2e/federation/devices/{fnid}` | `fnfed-v1` | List that account's devices |
| `GET /e2e/federation/devices/{fnid}` | `fnfed-v1` | List that account's devices, identity keys only (no `created_at` or `last_seen` since 2026-10-07) |
These sit under the same `/e2e` prefix as everything else, and under
`/api/flamenet/v1/e2e` as well, because the peer surface is mounted with the
+7 -3
View File
@@ -143,15 +143,19 @@ Defaults are sane; raise them only with a reason.
| Variable | Default | Guards |
|---|---|---|
| `FN_BUNDLE_RATE` | `120`/hr | Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone, that would let one abuser make a popular account unstartable for everyone. |
| `FN_BUNDLE_RATE` | `600`/hr | Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
| `FN_BUNDLE_RATE_TARGET` | `60`/hr | Per requester-target pair. Deliberately **not** metered per target alone, that would let one abuser make a popular account unstartable for everyone. The anonymous `POST /e2e/anon/bundles` uses the same two numbers per address and per address-target pair, or this one per presence token when the caller presents one. |
| `FN_SIGNUP_MAX_PER_HOUR` | `50`/hr | New accounts per IP. An IP is not a person, carrier CGNAT, offices and campuses all share one. It was `5`, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
| `FN_SIGNUP_MAX_PER_NETWORK` | `400`/hr | New accounts per network, a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. **`0` switches network counting off**, leaving only the per-address limit. |
| `FN_SIGNUP_POW_BITS` | `0` (off) | Leading zero bits of SHA-256 work a new account must do before it is created (relay#52). **Not a CAPTCHA on purpose**: a CAPTCHA hands a third party every visitor's connection, which is a strange thing for a product whose argument is that it collects almost nothing. This collects nothing at all. Capped at `24` however it is set: higher does not read as spam protection to the person it hits, it reads as an app that hangs on the sign-up button. |
| `FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW` | `5`/hr | What a client too old to answer the challenge gets instead, per address. This is what makes `FN_SIGNUP_POW_BITS` switchable on at all. Refusing an unanswered sign-up outright would break every build already in somebody's hand, and break it invisibly, the APK in the field was Android 0.6 when this landed, and the client-side solver came after it. An old build now drops to this strict allowance rather than hitting a wall, so the opt-out costs something without stranding anyone. Per-address is trivially rotated, so this bounds a lazy attacker, not a determined one; the gain is that the strong thing can be enabled at all. **`0` restores the wall**: the right setting once both shipped clients solve challenges. |
| `FN_VOUCH_REQUIRED` | `false` | Require an invite from an existing member to sign up (relay#43). **Inert on its own**: without `FN_VOUCH_UNTIL` the gate never engages, deliberately: an invite-only server nobody can join is worse than an open one. |
| `FN_VOUCH_UNTIL` | unset | Unix seconds after which vouching stops being required. **The gate turns itself off.** Vouching makes bulk sign-ups costly and gives every account a provenance; it also slows genuine growth and creates an in-group. That is a reasonable trade for a phase and a bad one for a policy, so it cannot be switched on without saying when it stops. |
| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. |
| `FN_SEND_RATE` | `3000`/hr | Envelopes per sender for an identified send. For a sealed send, envelopes per address, and the recipient-wide bucket, which since 2026-10-07 counts only **refused** sealed attempts (one per request): it exists to stop guessing, and when it counted accepted envelopes too, anybody holding one working delivery key could fill it and nobody else could reach that person for the hour. Once it is full, sealed sends to that person are refused for the rest of the hour whatever they carry, so a guess cannot be checked. |
| `FN_SEND_RATE_RECIPIENT_IP` | `FN_SEND_RATE` / 10 | Sealed envelopes from one address to one recipient. What bounds a sender with a working key now that accepted sends no longer fill the recipient-wide bucket, and what stops one address filling that bucket with refusals on its own. Keyed by a keyed hash of the recipient, never the account id. |
| `FN_INTRO_RATE` | `30`/hr | Introductions (`POST /e2e/intro`) per address. Charged before the handle or link is looked up, so a listed name, an unlisted one and an unknown one cost the same. |
| `FN_INTRO_RATE_NETWORK` | `240`/hr | Introductions per network, a /24 for IPv4 and a /48 for IPv6, the sign-up buckets. |
| `FN_INTRO_RATE_TARGET` | `30`/hr | Introductions per account, and a third of it per network and account, so one network cannot spend an account's whole hour. A crowd of networks still can: an account being flooded with introductions stops getting new ones for the hour, and the answer for that is Unlisted plus contact links. Separately, at most 90 unspent intro tokens (30 introductions) per account at once. |
| `FN_ATTACHMENT_RATE` | `60`/hr | |
| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |
| `FN_ATTACHMENT_MAX_BYTES` | `10 MiB` | Per blob. |
+14
View File
@@ -23,6 +23,20 @@ moment, which is why the issuer had to go and not only `used_by`.
`used_by` stays as an empty column until a later, separate migration drops it.
### Who asked about whom, in the access log
The protocol routes put the account being asked about in the URL
(`/e2e/keys/{id}`, `/e2e/devices/{id}`, `/e2e/account/identity/{handle}`,
`?slug=`), and the edge logs the URL beside the client address. The new
anonymous routes (`POST /e2e/intro`, `/e2e/anon/presence`, `/e2e/anon/devices`,
`/e2e/anon/bundles`) carry the target in the body, so their log line is a
method, a fixed path, an address and a size. Nothing changes until the apps
use them, and the old routes stay until the fleet has moved. The address is
still seen and usually matches the same device's authenticated poll.
The limiter keys for the new meters, and for the sealed send's recipient
buckets, hold a keyed hash of the account instead of its id.
---
## Pass 1, 2026-09-07
+52 -1
View File
@@ -14,7 +14,8 @@ credit for saying so.
## Pass 3, 2026-10-07
Code changes on branch `privacy/wave0-relay` (pairs stage 2-A, padding stage
1.1, per-device digests), not yet deployed. Production counts below were read
1.1, per-device digests; then introductions, Unlisted, contact links and
anonymous reads), not yet deployed. Production counts below were read
on 2026-10-07 and are the "before".
### Three pair records nothing read
@@ -92,6 +93,56 @@ were 4-8K, 181 were 8-16K, 24 were 16-24K, none padded. 24 of 415 is about
6%, under the 10% that would move the first ladder step to 24 KiB, but that
is one snapshot of a day's mail and the summed counters are the real reading.
### Introductions: a first message that names nobody
A first message to somebody went unsealed, so the envelope named its sender,
because the sender held none of the recipient's capabilities yet.
`POST /e2e/intro` (anonymous, target in the body) now hands a stranger three
single-use tokens for ten minutes. New table `intro_tokens`: token hash,
account, expiry. Nothing about the asker, and a test reads every row of every
table after an introduction and a send and finds the asker's address in none.
Rows go when spent or when the hourly sweep finds them expired (`intros` on
the sweep line).
What this adds to a dump: for each account, how many introductions are
waiting right now (at most 90 tokens, 30 introductions). It does not say to
whom or from where.
The sealed path's recipient bucket used to count every accepted envelope, so
anybody with one working delivery key could fill it. It now counts refused
attempts only, plus a per-(recipient, address) bucket. Meters about an
account are keyed by a keyed hash (`LimiterKeys`), so Redis holds no account
ids for these; the older meters (`bundle:`, `lookup:`, `deltokens:`,
`prekeys:`) still do.
### The device directory no longer says when anybody was last on their phone
`GET /e2e/devices/{id}` gave any member each device's `created_at` and
`last_seen` for any account, unmetered. Now only the account itself gets
them (from that route, kept for the two apps' Devices screens, which read
`created_at`, and from `GET /e2e/devices/me`). Other members, federated
peers and the anonymous directory get identity keys only. Nothing stored
changed; what is served did.
### Listed and Unlisted
`users.discoverable`, true for every existing account. Off, the five doors
that turn a name into an account (exact lookup, add by username, handle
resolution, id to profile, introduction by handle) answer as for a missing
name, measured: 500 samples an arm, interleaved, medians within noise of each
other on every door (largest gap 113 us on a 12.5 ms median, the sign
flipping between doors), and the floor holding every sample at or above 5 ms.
The unfloored lookup underneath differs by 3.6 us (149 against 146 us), which
is what the floor covers. The sign-up form's "taken" answer cannot be closed
while usernames are unique.
Contact links: `contact_links` holds a hash and an account, at most five per
account. No creation time, no record of use.
Account deletion now removes the account's `delivery_tokens` too (it left
them behind), along with its intro tokens and links. Production before,
2026-10-07: 5,357 delivery-token rows over 9 accounts, none orphaned.
---
## Pass 2, 2026-09-07