Keep delivery-key and roster-tag digests per device (DA4a)
Each device mints its own delivery key and its own presence tokens, and the relay kept one digest and one token set per account, so the second device to publish replaced the first. Its contacts' sealed sends were refused (the apps then send unsealed) and its contacts saw the account as offline. PerDeviceDigestTests reproduces both and failed on the old code. - New tables device_delivery_keys (unique user_id, device_id) and device_roster_tags (unique user_id, device_id, digest). Additive: delivery_keys and roster_tags are untouched, their rows keep working as account-wide rows because the publishing device was never recorded. Revert drops only the new tables. - POST /e2e/delivery-key writes the token's device row when the device is registered, else the account-wide row as before. The account-wide row is dropped once every device on the account has its own: both apps publish once and never again unless they rotate, so dropping it sooner would refuse sealed sends to devices that will not publish again. - The sealed check compares the presented key against every device digest and the account-wide one, padded to FN_MAX_DEVICES + 1 comparisons, with no early exit. - POST /messenger/roster/tags takes an optional device_id (one of this account's devices). With it, it replaces that device's set and removes the account-wide set, which may be the same device's older one. Without it, an older app replaces the account-wide set exactly as before. - Presence looks in the device sets, then the account-wide set. - DeviceRetirement (revoke, expiry, make room) deletes the device's rows from both new tables. key_log only gains its usual leaf. Account deletion now also removes roster tags, which it missed before. The orphan sweep covers the new tables. - The descriptor carries features.perDeviceDigests: true, so an app can publish its digest once more and send device_id. /metadata (en, es), API.md and its page, METADATA-PASSES.md and the vacuum script's table list updated (roster_tags was missing there too). Upgrade with populated legacy tables tested on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ea3e33484b
commit
aca12ca7f3
@@ -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: 67af6acc2b40cbec -->
|
||||
source-sha256: 482337e6be3ddc29 -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="relay-api">Relay API</h1>
|
||||
@@ -57,13 +57,17 @@ and every client that read it broke.</p>
|
||||
"accounts": "local",
|
||||
"accountsURL": null,
|
||||
"reports": true,
|
||||
"features": { "padAttachments": false }
|
||||
"features": { "padAttachments": false, "perDeviceDigests": true }
|
||||
}</code></pre>
|
||||
<p><code>features</code> holds switches a client reads before doing
|
||||
something an older client could not handle.
|
||||
<code>features.padAttachments</code> says whether senders may pad
|
||||
attachments (<code>FN_PAD_ATTACHMENTS</code>); a client that does not
|
||||
find the key treats it as false.</p>
|
||||
find the key treats it as false. <code>features.perDeviceDigests</code>
|
||||
says the relay keeps a delivery-key digest and a presence-token set per
|
||||
device rather than per account, so an app may publish its digest again
|
||||
to land in its own row, and may send <code>device_id</code> with its
|
||||
roster tags.</p>
|
||||
<p><strong><code>accounts</code> decides where a passphrase
|
||||
goes.</strong> <code>local</code> means this relay is its own identity
|
||||
provider and authenticates the password itself. <code>shared</code>
|
||||
@@ -236,7 +240,9 @@ the returned <code>cursor</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/delivery-key</code></td>
|
||||
<td>Publish a delivery-key digest for sealed sending (§14.4)</td>
|
||||
<td>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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/delivery-tokens</code></td>
|
||||
@@ -522,7 +528,9 @@ is not a directory</td>
|
||||
<tr>
|
||||
<td><code>POST /roster/tags</code></td>
|
||||
<td>Basic</td>
|
||||
<td>Publish the contact list as opaque tokens (§8.9c)</td>
|
||||
<td>Publish the contact list as opaque tokens (§8.9c). With
|
||||
<code>device_id</code> (one of this account's devices) it replaces that
|
||||
device's set; without, the account-wide set an older app publishes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /presence</code></td>
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
<tr><td><strong>Cómo se llama cada grupo</strong></td><td>No se guarda. Antes sí, y no debía: el nombre estaba en una columna que el servidor nunca usaba para nada, así que “Apoyo en la quimioterapia” o el nombre de una campaña sindical estaban en una máquina que puede ser asaltada o recibir una orden judicial, y no le servía a nadie. Ya no está, y los nombres que ya se habían guardado se borraron, no solo se dejaron de escribir. Los únicos lugares donde puede vivir el nombre ahora son los teléfonos de la gente del grupo, que se lo cuentan entre sí con el mismo cifrado que todo lo demás. Dos cosas se siguen de esto, y ninguna se te oculta. #if(iosLive):La mitad de las aplicaciones ya está en Android y en iPhone.#else:La mitad de las aplicaciones ya está en Android y en la beta de TestFlight del iPhone; hasta que llegue a tu teléfono, cada grupo se lee como “Grupo”, que es el servidor negándose a contestar, no algo perdido.#endif Y un grupo que creaste antes del cambio se muestra como “Grupo” hasta que alguien de dentro lo renombra, porque el nombre viejo se borró aquí y ningún teléfono había recibido nunca una copia: no queda nada de donde restaurarlo. Renombrar llegó en esa misma versión, exactamente por esto.</td></tr>
|
||||
<tr><td>Presencia</td><td>Se redondea a la ventana de conexión antes de escribirse, nunca al segundo</td></tr>
|
||||
<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: “0.25.0”, 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</td><td>Solo el hash. La clave en sí nunca llega al servidor salvo dentro de un envío sellado</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>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>
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
<tr><td><strong>What each group is called</strong></td><td>Not held. It once was, and it should not have been: the name sat in a column the server never actually used for anything, so “Chemotherapy support” or the name of a union drive was sitting on a machine that can be broken into or handed a court order, and it bought nobody anything. It is gone, and the names that were already stored were deleted rather than just stopped. The only places the name can live now are the phones of the people in the group, which tell each other over the same encryption everything else uses. Two things follow, and neither is hidden from you. #if(iosLive):The app half of this is out on Android and iPhone.#else:The app half of this is out now on Android and in the iPhone TestFlight beta, until it reaches your phone, every group reads as “Group”, which is the server declining to answer rather than anything lost.#endif And a group you made before the change shows as “Group” until somebody in it renames it, because the old name was deleted here and no phone had ever been given a copy: there is nothing left to restore it from. Renaming arrived in that same version, for exactly this reason.</td></tr>
|
||||
<tr><td>Presence</td><td>Coarsened to the online window before it is written, never to the second</td></tr>
|
||||
<tr><td>Which app version each device last polled with</td><td>The marketing version and nothing else: “0.25.0”, 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</td><td>The hash only. The key itself never reaches the server except inside a sealed submission</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’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>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>
|
||||
|
||||
@@ -25,7 +25,8 @@ set -uo pipefail
|
||||
|
||||
TABLES="buddies conversation_state notifications group_members groups users
|
||||
delivery_keys key_log push_tokens devices blocks app_credentials
|
||||
group_tags prekeys pq_prekeys delivery_tokens envelopes attachments"
|
||||
group_tags roster_tags device_delivery_keys device_roster_tags prekeys pq_prekeys
|
||||
delivery_tokens envelopes attachments"
|
||||
|
||||
before=$(docker exec messenger-db-1 psql -U flamenet -d flamenet -tAc \
|
||||
"SELECT COALESCE(sum(n_dead_tup),0) FROM pg_stat_user_tables;" 2>/dev/null)
|
||||
|
||||
@@ -5,7 +5,7 @@ import Foundation
|
||||
///
|
||||
/// Three paths remove one: a person revoking it from the Devices screen, the
|
||||
/// retention sweep expiring a silent one, and a sign-in on a new phone making
|
||||
/// room for itself. Each has to take the same six things with it, and a path
|
||||
/// room for itself. Each has to take the same eight things with it, and a path
|
||||
/// that forgets one leaves prekeys nobody will ever use or a mailbox nothing
|
||||
/// will ever collect, so the teardown lives here once.
|
||||
///
|
||||
@@ -35,6 +35,11 @@ enum DeviceRetirement {
|
||||
try await PQPreKey.query(on: db).filter(\.$deviceID == id).delete()
|
||||
try await Envelope.query(on: db).filter(\.$toDevice == id).delete()
|
||||
try await PushToken.query(on: db).filter(\.$id == id).delete()
|
||||
// Its delivery-key digest and its presence tokens, so a retired
|
||||
// device's key no longer opens the inbox and its tokens no longer
|
||||
// show presence. Never the key log, below.
|
||||
try await DeviceDeliveryKey.query(on: db).filter(\.$deviceID == id).delete()
|
||||
try await DeviceRosterTag.query(on: db).filter(\.$deviceID == id).delete()
|
||||
|
||||
// Written before the row goes: the log only ever grows, so a device
|
||||
// leaving is an entry rather than a deletion.
|
||||
@@ -45,6 +50,8 @@ enum DeviceRetirement {
|
||||
ikSig: device.ikSig, ikDH: device.ikDH, kind: reason.rawValue, on: db)
|
||||
|
||||
try await device.delete(on: db)
|
||||
// With this device gone, the rest may now all have their own key.
|
||||
try await PerDeviceDigests.retireAccountWideKeyIfCovered(userID: device.userID, on: db)
|
||||
}
|
||||
|
||||
/// How recently a device was used, for deciding which one goes first.
|
||||
|
||||
@@ -794,6 +794,8 @@ enum RelayMigrations {
|
||||
// the tuning above, so a table created from here on applies its
|
||||
// own autovacuum settings.
|
||||
ForgetVouchProvenance(), ClearConversationFlags(),
|
||||
// Additive: new tables beside delivery_keys and roster_tags.
|
||||
CreatePerDeviceDigests(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// A device's sealed-sender delivery-key digest (§14.4), one per device.
|
||||
///
|
||||
/// Each device mints its own delivery key, and `delivery_keys` holds one
|
||||
/// digest per ACCOUNT, so the second device to publish replaced the first:
|
||||
/// contacts holding the first device's key were refused and fell back to
|
||||
/// sending unsealed. Rows here are keyed by account and device instead, and a
|
||||
/// sealed send is accepted against any of the account's.
|
||||
///
|
||||
/// `delivery_keys` stays, as the account-wide row for whatever was published
|
||||
/// before this table existed. Nobody knows which device published it, so it
|
||||
/// is accepted until every device on the account has a row here, and then it
|
||||
/// goes (`PerDeviceDigests.retireAccountWideKeyIfCovered`). No timestamp: when
|
||||
/// a device last rotated its key is nothing anybody needs.
|
||||
final class DeviceDeliveryKey: Model, @unchecked Sendable {
|
||||
static let schema = "device_delivery_keys"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
@Field(key: "user_id") var userID: Int
|
||||
@Field(key: "device_id") var deviceID: String
|
||||
/// Base64 of the 32-byte SHA-256 digest.
|
||||
@Field(key: "digest") var digest: String
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
/// One contact's presence token, as published by one device (§8.9c).
|
||||
///
|
||||
/// Same reasoning as `DeviceDeliveryKey`. Each device issues its own tokens
|
||||
/// to the contacts it talks to, and `roster_tags` replaced the account's set
|
||||
/// whole on every publish, so a second device's publish took presence away
|
||||
/// from everybody holding the first device's tokens. A device that names
|
||||
/// itself now replaces only its own set.
|
||||
///
|
||||
/// `roster_tags` stays as the account-wide set an older app publishes without
|
||||
/// a device id. A device that publishes its own set takes the account-wide one
|
||||
/// away, because that set may be its own older one and keeping it would undo
|
||||
/// any token it has since revoked.
|
||||
final class DeviceRosterTag: Model, @unchecked Sendable {
|
||||
static let schema = "device_roster_tags"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
/// Whose presence the token shows. The subject, never the contact.
|
||||
@Field(key: "user_id") var userID: Int
|
||||
@Field(key: "device_id") var deviceID: String
|
||||
/// Base64 of SHA-256 of the contact's token.
|
||||
@Field(key: "digest") var digest: String
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
enum PerDeviceDigests {
|
||||
|
||||
/// Drop the account-wide delivery-key row once every device on the account
|
||||
/// has published its own.
|
||||
///
|
||||
/// Not sooner. Both apps publish their digest once and never again unless
|
||||
/// they rotate, so an account-wide row written before this change may be
|
||||
/// the only digest a device has, and removing it early would refuse its
|
||||
/// contacts' sealed sends for as long as the app goes without rotating.
|
||||
static func retireAccountWideKeyIfCovered(userID: Int, on db: any Database) async throws {
|
||||
let devices = Set(
|
||||
try await Device.query(on: db).filter(\.$userID == userID).all().compactMap(\.id))
|
||||
let published = Set(
|
||||
try await DeviceDeliveryKey.query(on: db).filter(\.$userID == userID).all()
|
||||
.map(\.deviceID))
|
||||
guard !devices.isEmpty, devices.isSubset(of: published) else { return }
|
||||
try await DeliveryKey.query(on: db).filter(\.$id == userID).delete()
|
||||
}
|
||||
|
||||
/// The digests a sealed send is compared against, always `slots` long.
|
||||
///
|
||||
/// Padded with `filler` (a per-process random value no digest can equal)
|
||||
/// so the number of comparisons does not depend on how many devices the
|
||||
/// recipient has published for, or whether it has published at all
|
||||
/// (§14.5). More rows than slots, possible for a moment after the device
|
||||
/// cap is lowered, are all kept: refusing a real key would be worse than a
|
||||
/// longer loop.
|
||||
static func sealedSlots(_ published: [Data], slots: Int, filler: Data) -> [Data] {
|
||||
published + Array(repeating: filler, count: max(0, slots - published.count))
|
||||
}
|
||||
}
|
||||
|
||||
/// The two per-device tables (2026-10-07).
|
||||
///
|
||||
/// Additive: `delivery_keys` and `roster_tags` are untouched and every row in
|
||||
/// them keeps working as an account-wide row. The publishing device of those
|
||||
/// rows was never recorded, so there is nothing to assign them to. Revert
|
||||
/// drops only the new tables.
|
||||
struct CreatePerDeviceDigests: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(DeviceDeliveryKey.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("user_id", .int, .required)
|
||||
.field("device_id", .string, .required)
|
||||
.field("digest", .string, .required)
|
||||
.unique(on: "user_id", "device_id")
|
||||
.create()
|
||||
try await database.schema(DeviceRosterTag.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("user_id", .int, .required)
|
||||
.field("device_id", .string, .required)
|
||||
.field("digest", .string, .required)
|
||||
// Two devices may publish the same token once they share an
|
||||
// account's tokens, so the device is part of what is unique.
|
||||
.unique(on: "user_id", "device_id", "digest")
|
||||
.create()
|
||||
if let sql = database as? any SQLDatabase {
|
||||
// The sealed check reads every row for one account.
|
||||
try await sql.raw(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS device_delivery_keys_user
|
||||
ON device_delivery_keys (user_id)
|
||||
"""
|
||||
).run()
|
||||
// Presence is asked by digest alone, never by account.
|
||||
try await sql.raw(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS device_roster_tags_digest
|
||||
ON device_roster_tags (digest)
|
||||
"""
|
||||
).run()
|
||||
// Retirement deletes by device.
|
||||
try await sql.raw(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS device_roster_tags_device
|
||||
ON device_roster_tags (device_id)
|
||||
"""
|
||||
).run()
|
||||
}
|
||||
// The same autovacuum settings as every other table holding user data
|
||||
// (`TuneUserDataAutovacuum`, which ran before these tables existed).
|
||||
// Postgres only, gated on the dialect for the reason that migration
|
||||
// gives.
|
||||
if let pg = TuneUserDataAutovacuum.postgres(database) {
|
||||
for table in [DeviceDeliveryKey.schema, DeviceRosterTag.schema] {
|
||||
try await pg.raw(
|
||||
"""
|
||||
ALTER TABLE \(unsafeRaw: table) SET \
|
||||
(autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.0)
|
||||
"""
|
||||
).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(DeviceRosterTag.schema).delete()
|
||||
try await database.schema(DeviceDeliveryKey.schema).delete()
|
||||
}
|
||||
}
|
||||
@@ -268,7 +268,12 @@ struct Retention: @unchecked Sendable {
|
||||
try await purge("prekeys", "device_id")
|
||||
+ purge("pq_prekeys", "device_id")
|
||||
let envelopes = try await purge("envelopes", "to_device")
|
||||
if prekeys > 0 || envelopes > 0 {
|
||||
// Digests of a device that is gone open nothing useful, but they are
|
||||
// still a count of somebody's contacts.
|
||||
let digests =
|
||||
try await purge("device_delivery_keys", "device_id")
|
||||
+ purge("device_roster_tags", "device_id")
|
||||
if prekeys > 0 || envelopes > 0 || digests > 0 {
|
||||
// At notice, not debug: in ordinary running this is always zero,
|
||||
// so any number at all means a device row went out of band.
|
||||
logger.notice(
|
||||
@@ -276,6 +281,7 @@ struct Retention: @unchecked Sendable {
|
||||
metadata: [
|
||||
"prekeys": .string("\(prekeys)"),
|
||||
"envelopes": .string("\(envelopes)"),
|
||||
"digests": .string("\(digests)"),
|
||||
])
|
||||
}
|
||||
return (prekeys, envelopes)
|
||||
|
||||
@@ -461,9 +461,13 @@ struct Routes: Sendable {
|
||||
|
||||
// MARK: - §14.4 POST /e2e/delivery-key
|
||||
|
||||
/// Publish `SHA-256(delivery_key)` for the calling account.
|
||||
/// Publish `SHA-256(delivery_key)` for the calling device.
|
||||
///
|
||||
/// Per device since 2026-10-07 (see `DeviceDeliveryKey`). The token
|
||||
/// already names the device, so no client has to change for this: an
|
||||
/// existing app's next publish lands in its own row.
|
||||
func publishDeliveryKey(_ request: Request, body: Data, now: Int) async throws -> Response {
|
||||
let caller = try await authenticate(
|
||||
let (caller, known) = try await authenticated(
|
||||
request, body: body, path: "/e2e/delivery-key", now: now)
|
||||
let input = try decodeBody(DeliveryKeyRequest.self, from: body)
|
||||
|
||||
@@ -472,6 +476,30 @@ struct Routes: Sendable {
|
||||
throw WireError.badRequest("digest must be a base64 32-byte SHA-256.")
|
||||
}
|
||||
|
||||
// A registered device gets its own row. A token whose device has not
|
||||
// registered yet still publishes the account-wide row, as before:
|
||||
// a row keyed by a device that does not exist would be swept as an
|
||||
// orphan, and the apps do not publish twice.
|
||||
if let known, let deviceID = known.id, known.userID == caller.userID {
|
||||
try await request.db.retryingTransaction { db in
|
||||
if let existing = try await DeviceDeliveryKey.query(on: db)
|
||||
.filter(\.$userID == caller.userID).filter(\.$deviceID == deviceID).first()
|
||||
{
|
||||
existing.digest = digest.base64EncodedString()
|
||||
try await existing.update(on: db)
|
||||
} else {
|
||||
let row = DeviceDeliveryKey()
|
||||
row.userID = caller.userID
|
||||
row.deviceID = deviceID
|
||||
row.digest = digest.base64EncodedString()
|
||||
try await row.create(on: db)
|
||||
}
|
||||
try await PerDeviceDigests.retireAccountWideKeyIfCovered(
|
||||
userID: caller.userID, on: db)
|
||||
}
|
||||
return try .json(OKResponse(ok: true))
|
||||
}
|
||||
|
||||
if let existing = try await DeliveryKey.find(caller.userID, on: request.db) {
|
||||
existing.digest = digest.base64EncodedString()
|
||||
try await existing.update(on: request.db)
|
||||
@@ -574,16 +602,29 @@ struct Routes: Sendable {
|
||||
// compare. `absentDigest` is fresh random bytes per process,
|
||||
// so a missing row compares against something no real digest
|
||||
// can equal rather than against nothing at all.
|
||||
//
|
||||
// Per device since 2026-10-07: every device's digest, plus the
|
||||
// account-wide row from before, padded to a fixed number of
|
||||
// slots (FN_MAX_DEVICES plus that one row) so the loop runs as
|
||||
// many times for an account with one device, six, or none.
|
||||
// Every slot is compared; nothing leaves the loop early.
|
||||
let raw = Data(base64Encoded: input.delivery_key!)!
|
||||
let presented = Data(SHA256.hash(data: raw))
|
||||
let published = try await DeliveryKey.find(input.to, on: request.db)
|
||||
let expected =
|
||||
published.flatMap { Data(base64Encoded: $0.digest) } ?? Self.absentDigest
|
||||
// The comparison runs first and unconditionally; `&&` then
|
||||
// short-circuits on a value already computed, so the presence
|
||||
// of the row costs nothing extra to check.
|
||||
let matched = constantTimeEquals(presented, expected)
|
||||
guard matched, published != nil else {
|
||||
let perDevice = try await DeviceDeliveryKey.query(on: request.db)
|
||||
.filter(\.$userID == input.to).all()
|
||||
let accountWide = try await DeliveryKey.find(input.to, on: request.db)
|
||||
let published =
|
||||
(perDevice.map(\.digest) + (accountWide.map { [$0.digest] } ?? []))
|
||||
.compactMap { Data(base64Encoded: $0) }
|
||||
let slots = PerDeviceDigests.sealedSlots(
|
||||
published, slots: max(config.maxDevicesPerAccount, 1) + 1,
|
||||
filler: Self.absentDigest)
|
||||
var matched = false
|
||||
for expected in slots {
|
||||
let equal = constantTimeEquals(presented, expected)
|
||||
matched = matched || equal
|
||||
}
|
||||
guard matched, !published.isEmpty else {
|
||||
throw await Self.sealedRefusal(since: checkBegan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +785,9 @@ extension AccountRoutes {
|
||||
try await Envelope.query(on: db).filter(\.$toUser == userID).delete()
|
||||
try await Envelope.query(on: db).filter(\.$fromUser == userID).delete()
|
||||
try await DeliveryKey.query(on: db).filter(\.$id == userID).delete()
|
||||
try await DeviceDeliveryKey.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await RosterTag.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await DeviceRosterTag.query(on: db).filter(\.$userID == userID).delete()
|
||||
|
||||
// Credentials last: while they exist the account is still reachable, so
|
||||
// they are what makes the deletion take effect.
|
||||
|
||||
@@ -36,6 +36,11 @@ extension Routes {
|
||||
/// From `FN_PAD_ATTACHMENTS`, false by default. camelCase like
|
||||
/// every other key in this document.
|
||||
let padAttachments: Bool
|
||||
/// Always true on a relay with per-device digests. Apps publish
|
||||
/// their delivery-key digest once; one that sees this can publish
|
||||
/// it again, once, so it lands in its own row, and can send its
|
||||
/// `device_id` with its roster tags.
|
||||
let perDeviceDigests: Bool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +72,8 @@ extension Routes {
|
||||
accounts: shared ? "shared" : "local",
|
||||
accountsURL: shared ? Self.accountsURL : nil,
|
||||
reports: config.contentReportsEnabled,
|
||||
features: .init(padAttachments: config.padAttachments)
|
||||
features: .init(
|
||||
padAttachments: config.padAttachments, perDeviceDigests: true)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,34 @@ struct MobileRoutes: Sendable {
|
||||
"At most \(Self.maxRosterTags) contacts.")
|
||||
}
|
||||
|
||||
// A device that names itself replaces only its own set (2026-10-07,
|
||||
// see `DeviceRosterTag`). It has to be one of this account's devices:
|
||||
// the Basic credential names the account, not the device.
|
||||
if let deviceID = input.device_id {
|
||||
guard isValidDeviceID(deviceID) else { throw WireError.malformedDeviceID }
|
||||
guard let device = try await Device.find(deviceID, on: request.db),
|
||||
device.userID == me
|
||||
else { throw WireError.deviceNotYours }
|
||||
try await request.db.retryingTransaction { db in
|
||||
try await DeviceRosterTag.query(on: db)
|
||||
.filter(\.$userID == me).filter(\.$deviceID == deviceID).delete()
|
||||
for digest in digests {
|
||||
let row = DeviceRosterTag()
|
||||
row.userID = me
|
||||
row.deviceID = deviceID
|
||||
row.digest = digest
|
||||
try await row.create(on: db)
|
||||
}
|
||||
// The account-wide set may be this device's own older one,
|
||||
// and keeping it would undo every token the device has
|
||||
// revoked since. An older app on another device publishes it
|
||||
// again, which is no worse than it was: it used to replace
|
||||
// everybody's.
|
||||
try await RosterTag.query(on: db).filter(\.$userID == me).delete()
|
||||
}
|
||||
return try .json(["ok": true])
|
||||
}
|
||||
|
||||
// Replaced whole, in one transaction, or a failure half way through
|
||||
// leaves somebody's list of who may see them in a state neither they
|
||||
// nor this relay chose.
|
||||
@@ -143,10 +171,18 @@ struct MobileRoutes: Sendable {
|
||||
continue
|
||||
}
|
||||
let digest = Data(SHA256.hash(data: token)).base64EncodedString()
|
||||
guard
|
||||
let tag = try await RosterTag.query(on: request.db)
|
||||
.filter(\.$digest == digest).first()
|
||||
else {
|
||||
// A device's own set first, then the account-wide one an older
|
||||
// app publishes.
|
||||
let subject: Int?
|
||||
if let tag = try await DeviceRosterTag.query(on: request.db)
|
||||
.filter(\.$digest == digest).first()
|
||||
{
|
||||
subject = tag.userID
|
||||
} else {
|
||||
subject = try await RosterTag.query(on: request.db)
|
||||
.filter(\.$digest == digest).first()?.userID
|
||||
}
|
||||
guard let subject else {
|
||||
rows.append(.init(token: raw, presence: EffectiveStatus.offline.rawValue))
|
||||
continue
|
||||
}
|
||||
@@ -157,7 +193,7 @@ struct MobileRoutes: Sendable {
|
||||
.init(
|
||||
token: raw,
|
||||
presence: try await Self.statusIgnoringRoster(
|
||||
of: tag.userID, to: me, on: request.db
|
||||
of: subject, to: me, on: request.db
|
||||
).rawValue))
|
||||
}
|
||||
return try .json(PresenceByTokenResponse(presence: rows))
|
||||
|
||||
@@ -488,6 +488,9 @@ struct TurnResponse: Content {
|
||||
struct PublishRosterTagsRequest: Content {
|
||||
/// Base64 SHA-256 digests, one per contact.
|
||||
let digests: [String]
|
||||
/// The publishing device, from apps that send it. Absent, the set is the
|
||||
/// account-wide one, as every app before 2026-10-07 publishes.
|
||||
var device_id: String? = nil
|
||||
}
|
||||
|
||||
/// Ask after the people these tokens were issued for.
|
||||
|
||||
@@ -512,7 +512,10 @@ struct E2ERoutesTests {
|
||||
h.request(.POST, "/e2e/delivery-key", as: alice, body: body, now: now),
|
||||
body: body, now: now)
|
||||
|
||||
let row = try #require(try await DeliveryKey.find(alice.userID, on: h.db))
|
||||
// In this device's own row since digests went per device.
|
||||
let row = try #require(
|
||||
try await DeviceDeliveryKey.query(on: h.db)
|
||||
.filter(\.$deviceID == alice.deviceID).first())
|
||||
#expect(row.digest == digest)
|
||||
#expect(row.digest != raw.base64EncodedString())
|
||||
}
|
||||
@@ -532,10 +535,10 @@ struct E2ERoutesTests {
|
||||
h.request(.POST, "/e2e/delivery-key", as: alice, body: body, now: now),
|
||||
body: body, now: now)
|
||||
}
|
||||
#expect(try await DeliveryKey.query(on: h.db).count() == 1)
|
||||
#expect(try await DeviceDeliveryKey.query(on: h.db).count() == 1)
|
||||
let expected = Data(SHA256.hash(data: Data(repeating: 2, count: 32)))
|
||||
.base64EncodedString()
|
||||
#expect(try await DeliveryKey.find(alice.userID, on: h.db)?.digest == expected)
|
||||
#expect(try await DeviceDeliveryKey.query(on: h.db).first()?.digest == expected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Delivery-key and roster-tag digests per device, not per account
|
||||
/// (compete design DA4a, padding-and-pairs finding 0.6; 2026-10-07).
|
||||
///
|
||||
/// Each device mints its own delivery key and its own presence tokens, and
|
||||
/// the relay kept one digest, and one token set, per ACCOUNT. So the second
|
||||
/// device to publish overwrote the first: contacts holding the first device's
|
||||
/// key were refused and fell back to unsealed sending, and contacts holding
|
||||
/// the first device's tokens saw the account as offline.
|
||||
@Suite("per-device digests")
|
||||
struct PerDeviceDigestTests {
|
||||
|
||||
private let now = 1_700_000_000
|
||||
private let ip = "203.0.113.9"
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func key() -> Data { SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } }
|
||||
private func digest(_ raw: Data) -> String { Data(SHA256.hash(data: raw)).base64EncodedString() }
|
||||
|
||||
private func publishDeliveryKey(
|
||||
_ h: RelayHarness, _ d: RelayHarness.TestDevice, _ raw: Data
|
||||
) async throws {
|
||||
let body = Data(#"{"digest":"\#(digest(raw))"}"#.utf8)
|
||||
let req = h.request(.POST, "/e2e/delivery-key", as: d, body: body, now: now)
|
||||
_ = try await h.routes.publishDeliveryKey(req, body: body, now: now)
|
||||
}
|
||||
|
||||
/// A sealed send to `user`'s device, authorised by `raw`. True if accepted.
|
||||
private func sealedSend(
|
||||
_ h: RelayHarness, to user: Int, device: String, with raw: Data
|
||||
) async throws -> Bool {
|
||||
let body = Data(
|
||||
#"{"to":\#(user),"messages":[{"to_device":"\#(device)","payload":"x"}],"delivery_key":"\#(raw.base64EncodedString())"}"#
|
||||
.utf8)
|
||||
do {
|
||||
let res = try await h.routes.send(
|
||||
h.anonymousRequest(.POST, "/e2e/messages", body: body), body: body,
|
||||
clientIP: ip, now: now)
|
||||
return res.status == .ok
|
||||
} catch let e as WireError where e.code == FlamenetRelay.Routes.sealedRejection.code {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func mobile(_ h: RelayHarness) -> MobileRoutes {
|
||||
MobileRoutes(
|
||||
accounts: AccountRoutes(
|
||||
issuer: Issuer(
|
||||
signingKeyRaw: Data(repeating: 1, count: 32),
|
||||
audience: "test.relay", lifetime: 300),
|
||||
limits: h.routes.limits, signupMaxPerHour: 100),
|
||||
limits: h.routes.limits)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func account(_ h: RelayHarness, id: Int, name: String) async throws -> String {
|
||||
let u = User()
|
||||
u.id = id
|
||||
u.username = name
|
||||
u.email = "\(name)@example.com"
|
||||
u.passwordHash = "x"
|
||||
u.displayName = name
|
||||
u.chosenStatus = ChosenStatus.online.rawValue
|
||||
u.lastSeenAt = Date()
|
||||
try await u.create(on: h.db)
|
||||
let selector = String(randomHexToken().prefix(12))
|
||||
let secret = randomHexToken()
|
||||
let c = AppCredential()
|
||||
c.userID = id
|
||||
c.selector = selector
|
||||
c.secretHash = try Bcrypt.hash(secret)
|
||||
c.label = "phone"
|
||||
try await c.create(on: h.db)
|
||||
return "\(selector).\(secret)"
|
||||
}
|
||||
|
||||
private func basic(
|
||||
_ h: RelayHarness, _ path: String, credential: String, body: String
|
||||
) -> Request {
|
||||
var headers = HTTPHeaders()
|
||||
headers.basicAuthorization = .init(username: "any", password: credential)
|
||||
headers.contentType = .json
|
||||
var buf = ByteBufferAllocator().buffer(capacity: body.utf8.count)
|
||||
buf.writeString(body)
|
||||
return Request(
|
||||
application: h.app, method: .POST, url: URI(path: path),
|
||||
headers: headers, collectedBody: buf, on: h.app.eventLoopGroup.next())
|
||||
}
|
||||
|
||||
/// Publish a token set, as a device when `device` is given and as the
|
||||
/// whole account (an old client) when it is not.
|
||||
private func publishTags(
|
||||
_ h: RelayHarness, credential: String, device: String?, _ tokens: [Data]
|
||||
) async throws {
|
||||
let list = tokens.map { "\"\(digest($0))\"" }.joined(separator: ",")
|
||||
let deviceField = device.map { #","device_id":"\#($0)""# } ?? ""
|
||||
_ = try await mobile(h).publishRosterTags(
|
||||
basic(
|
||||
h, "/messenger/roster/tags", credential: credential,
|
||||
body: #"{"digests":[\#(list)]\#(deviceField)}"#))
|
||||
}
|
||||
|
||||
private func presence(_ h: RelayHarness, credential: String, _ token: Data) async throws
|
||||
-> String
|
||||
{
|
||||
let res = try await mobile(h).presenceByToken(
|
||||
basic(
|
||||
h, "/messenger/presence", credential: credential,
|
||||
body: #"{"tokens":["\#(token.base64EncodedString())"]}"#))
|
||||
let decoded = try JSONDecoder().decode(
|
||||
PresenceByTokenResponse.self, from: Data((res.body.string ?? "").utf8))
|
||||
return decoded.presence.first?.presence ?? "missing"
|
||||
}
|
||||
|
||||
// MARK: - The bug
|
||||
|
||||
@Test("a second device publishing its delivery key does not cut off the first")
|
||||
func secondDeliveryKeyDoesNotOverwrite() async throws {
|
||||
try await withRelay { h in
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let phoneKey = key()
|
||||
let tabletKey = key()
|
||||
|
||||
try await publishDeliveryKey(h, phone, phoneKey)
|
||||
try await publishDeliveryKey(h, tablet, tabletKey)
|
||||
|
||||
#expect(
|
||||
try await sealedSend(h, to: 1, device: phone.deviceID, with: phoneKey),
|
||||
"the phone's contacts were refused once the tablet published")
|
||||
#expect(try await sealedSend(h, to: 1, device: tablet.deviceID, with: tabletKey))
|
||||
#expect(!(try await sealedSend(h, to: 1, device: phone.deviceID, with: key())))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a second device publishing its roster tags does not cut off the first's contacts")
|
||||
func secondTagSetDoesNotOverwrite() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let fromPhone = key()
|
||||
let fromTablet = key()
|
||||
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [fromPhone])
|
||||
try await publishTags(h, credential: me, device: tablet.deviceID, [fromTablet])
|
||||
|
||||
#expect(
|
||||
try await presence(h, credential: viewer, fromPhone) == "online",
|
||||
"the phone's contacts lost presence once the tablet published")
|
||||
#expect(try await presence(h, credential: viewer, fromTablet) == "online")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Older apps and rows from before
|
||||
|
||||
/// An app that sends no device id still publishes, and is still the
|
||||
/// account-wide set it always was.
|
||||
@Test("a roster publish without a device id still works")
|
||||
func oldClientRosterStillWorks() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let token = key()
|
||||
try await publishTags(h, credential: me, device: nil, [token])
|
||||
#expect(try await presence(h, credential: viewer, token) == "online")
|
||||
#expect(try await RosterTag.query(on: h.db).count() == 1)
|
||||
#expect(try await DeviceRosterTag.query(on: h.db).count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The account-wide set may be this device's own older one; keeping it
|
||||
/// would undo a revocation.
|
||||
@Test("a device publishing its own set takes the account-wide set away")
|
||||
func perDevicePublishReplacesAccountWide() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
try await h.register(phone, on: h.db)
|
||||
let revoked = key()
|
||||
let kept = key()
|
||||
try await publishTags(h, credential: me, device: nil, [revoked, kept])
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [kept])
|
||||
#expect(try await presence(h, credential: viewer, revoked) == "offline")
|
||||
#expect(try await presence(h, credential: viewer, kept) == "online")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a device revokes one of its own tokens without touching another device's")
|
||||
func perDeviceRevocation() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let dropped = key()
|
||||
let fromTablet = key()
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [dropped])
|
||||
try await publishTags(h, credential: me, device: tablet.deviceID, [fromTablet])
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [])
|
||||
#expect(try await presence(h, credential: viewer, dropped) == "offline")
|
||||
#expect(try await presence(h, credential: viewer, fromTablet) == "online")
|
||||
}
|
||||
}
|
||||
|
||||
/// Two devices sharing an account's tokens, as linking will make them.
|
||||
@Test("two devices may publish the same token")
|
||||
func sameTokenOnTwoDevices() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let shared = key()
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [shared])
|
||||
try await publishTags(h, credential: me, device: tablet.deviceID, [shared])
|
||||
#expect(try await DeviceRosterTag.query(on: h.db).count() == 2)
|
||||
#expect(try await presence(h, credential: viewer, shared) == "online")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a device id that is not one of the account's is refused")
|
||||
func foreignDeviceRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
_ = try await account(h, id: 2, name: "bob")
|
||||
let theirs = h.device(user: 2, id: "bbbb-0002")
|
||||
try await h.register(theirs, on: h.db)
|
||||
for device in [theirs.deviceID, "cccc-0003", "not a device"] {
|
||||
var refused = false
|
||||
do { try await publishTags(h, credential: me, device: device, [key()]) } catch is
|
||||
WireError
|
||||
{ refused = true }
|
||||
#expect(refused, "\(device) was accepted")
|
||||
}
|
||||
#expect(try await DeviceRosterTag.query(on: h.db).count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows written before the change carry no device, and both apps publish
|
||||
/// their key once, so the account-wide row has to keep working until
|
||||
/// every device has a row of its own.
|
||||
@Test("the account-wide key is accepted until every device has its own")
|
||||
func accountWideKeyKeptUntilCovered() async throws {
|
||||
try await withRelay { h in
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let old = key()
|
||||
let legacy = DeliveryKey()
|
||||
legacy.id = 1
|
||||
legacy.digest = digest(old)
|
||||
try await legacy.create(on: h.db)
|
||||
|
||||
let phoneKey = key()
|
||||
try await publishDeliveryKey(h, phone, phoneKey)
|
||||
#expect(
|
||||
try await sealedSend(h, to: 1, device: tablet.deviceID, with: old),
|
||||
"the account-wide key stopped working before the tablet had its own")
|
||||
#expect(try await sealedSend(h, to: 1, device: phone.deviceID, with: phoneKey))
|
||||
|
||||
let tabletKey = key()
|
||||
try await publishDeliveryKey(h, tablet, tabletKey)
|
||||
#expect(try await DeliveryKey.find(1, on: h.db) == nil)
|
||||
#expect(!(try await sealedSend(h, to: 1, device: tablet.deviceID, with: old)))
|
||||
#expect(try await sealedSend(h, to: 1, device: tablet.deviceID, with: tabletKey))
|
||||
}
|
||||
}
|
||||
|
||||
/// A token whose device has not registered still publishes, to the
|
||||
/// account-wide row, as before.
|
||||
@Test("a delivery key from an unregistered device is account-wide")
|
||||
func unregisteredDeviceFallsBack() async throws {
|
||||
try await withRelay { h in
|
||||
let owner = h.device(user: 1, id: "aaaa-0001")
|
||||
try await h.register(owner, on: h.db)
|
||||
let early = h.device(user: 1, id: "aaaa-0009")
|
||||
let k = key()
|
||||
try await publishDeliveryKey(h, early, k)
|
||||
#expect(try await DeliveryKey.find(1, on: h.db)?.digest == digest(k))
|
||||
#expect(try await DeviceDeliveryKey.query(on: h.db).count() == 0)
|
||||
#expect(try await sealedSend(h, to: 1, device: owner.deviceID, with: k))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retirement
|
||||
|
||||
@Test("revoking a device takes its key and tokens, and never a key-log row")
|
||||
func revokeTakesItsDigests() async throws {
|
||||
try await withRelay { h in
|
||||
let me = try await account(h, id: 1, name: "ada")
|
||||
let viewer = try await account(h, id: 2, name: "bob")
|
||||
let phone = h.device(user: 1, id: "aaaa-0001")
|
||||
let tablet = h.device(user: 1, id: "aaaa-0002")
|
||||
try await h.register(phone, on: h.db)
|
||||
try await h.register(tablet, on: h.db)
|
||||
let phoneKey = key()
|
||||
let tabletKey = key()
|
||||
let phoneToken = key()
|
||||
let tabletToken = key()
|
||||
try await publishDeliveryKey(h, phone, phoneKey)
|
||||
try await publishDeliveryKey(h, tablet, tabletKey)
|
||||
try await publishTags(h, credential: me, device: phone.deviceID, [phoneToken])
|
||||
try await publishTags(h, credential: me, device: tablet.deviceID, [tabletToken])
|
||||
let logBefore = try await LogEntry.query(on: h.db).count()
|
||||
|
||||
// From the tablet, through the real route.
|
||||
let req = h.request(
|
||||
.DELETE, "/e2e/devices/\(phone.deviceID)", as: tablet, now: now)
|
||||
_ = try await h.routes.revokeDevice(req, deviceID: phone.deviceID, now: now)
|
||||
|
||||
#expect(!(try await sealedSend(h, to: 1, device: tablet.deviceID, with: phoneKey)))
|
||||
#expect(try await sealedSend(h, to: 1, device: tablet.deviceID, with: tabletKey))
|
||||
#expect(try await presence(h, credential: viewer, phoneToken) == "offline")
|
||||
#expect(try await presence(h, credential: viewer, tabletToken) == "online")
|
||||
#expect(
|
||||
try await DeviceDeliveryKey.query(on: h.db)
|
||||
.filter(\.$deviceID == phone.deviceID).count() == 0)
|
||||
#expect(
|
||||
try await LogEntry.query(on: h.db).count() == logBefore + 1,
|
||||
"retirement must append one leaf and delete none")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comparison
|
||||
|
||||
/// The loop is as long for an account with no devices as for one with
|
||||
/// many, so its length does not say how many keys were published.
|
||||
@Test("the sealed comparison always runs over the same number of slots")
|
||||
func slotsArePadded() {
|
||||
let filler = Data(repeating: 9, count: 32)
|
||||
let one = Data(repeating: 1, count: 32)
|
||||
for published in [[], [one], [one, one, one]] {
|
||||
#expect(PerDeviceDigests.sealedSlots(published, slots: 7, filler: filler).count == 7)
|
||||
}
|
||||
#expect(
|
||||
PerDeviceDigests.sealedSlots(Array(repeating: one, count: 9), slots: 7, filler: filler)
|
||||
.count == 9, "a real digest was dropped to fit the slots")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ enum WaveZeroUpgrade {
|
||||
"""
|
||||
).run()
|
||||
|
||||
// Account-wide digests from before the per-device tables.
|
||||
try await sql.raw(
|
||||
"INSERT INTO delivery_keys (user_id, digest) VALUES (1, 'legacy-key')"
|
||||
).run()
|
||||
try await sql.raw(
|
||||
"INSERT INTO roster_tags (user_id, digest) VALUES (1, 'legacy-tag')"
|
||||
).run()
|
||||
|
||||
for m in all[cut...] { app.migrations.add(m) }
|
||||
try await app.autoMigrate()
|
||||
|
||||
@@ -60,6 +68,28 @@ enum WaveZeroUpgrade {
|
||||
let convo = try #require(try await ConversationState.query(on: app.db).first())
|
||||
#expect(!convo.starred && !convo.archived, "a star or archive survived")
|
||||
#expect(convo.accepted && convo.clearedToID == 9, "the backfill touched other columns")
|
||||
|
||||
// The account-wide rows survive, and the new tables take rows.
|
||||
#expect(try await DeliveryKey.find(1, on: app.db)?.digest == "legacy-key")
|
||||
#expect(try await RosterTag.query(on: app.db).first()?.digest == "legacy-tag")
|
||||
let k = DeviceDeliveryKey()
|
||||
k.userID = 1
|
||||
k.deviceID = "aaaa-0001"
|
||||
k.digest = "per-device"
|
||||
try await k.create(on: app.db)
|
||||
for device in ["aaaa-0001", "aaaa-0002"] {
|
||||
let t = DeviceRosterTag()
|
||||
t.userID = 1
|
||||
t.deviceID = device
|
||||
t.digest = "same-token"
|
||||
try await t.create(on: app.db)
|
||||
}
|
||||
#expect(try await DeviceRosterTag.query(on: app.db).count() == 2)
|
||||
|
||||
// And the migration reverts: only the new tables go.
|
||||
try await CreatePerDeviceDigests().revert(on: app.db)
|
||||
try await CreatePerDeviceDigests().prepare(on: app.db)
|
||||
#expect(try await DeliveryKey.find(1, on: app.db)?.digest == "legacy-key")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -34,14 +34,17 @@ credential**:
|
||||
"accounts": "local",
|
||||
"accountsURL": null,
|
||||
"reports": true,
|
||||
"features": { "padAttachments": false }
|
||||
"features": { "padAttachments": false, "perDeviceDigests": true }
|
||||
}
|
||||
```
|
||||
|
||||
`features` holds switches a client reads before doing something an older
|
||||
client could not handle. `features.padAttachments` says whether senders may
|
||||
pad attachments (`FN_PAD_ATTACHMENTS`); a client that does not find the key
|
||||
treats it as false.
|
||||
treats it as false. `features.perDeviceDigests` says the relay keeps a
|
||||
delivery-key digest and a presence-token set per device rather than per
|
||||
account, so an app may publish its digest again to land in its own row, and
|
||||
may send `device_id` with its roster tags.
|
||||
|
||||
**`accounts` decides where a passphrase goes.** `local` means this relay is
|
||||
its own identity provider and authenticates the password itself. `shared` means
|
||||
@@ -136,7 +139,7 @@ its inbox.
|
||||
| `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` |
|
||||
| `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 a delivery-key digest for sealed sending (§14.4) |
|
||||
| `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) |
|
||||
| `POST /e2e/attachments` | Upload one encrypted blob |
|
||||
| `GET /e2e/attachments/{id}` | Download it, supports `Range` |
|
||||
@@ -275,7 +278,7 @@ plaintext message transport that once lived here was removed (relay#50, and
|
||||
| `GET /buddies`, `POST /buddies` | Basic | The contact list, read and add |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `POST /presence` | Basic | Ask after presence by presenting a token, rather than by being on a list the relay can read |
|
||||
| `POST /status`, `POST /conversation` | Basic | Availability, and accepting a correspondent. `star`, `unstar`, `archive` and `unarchive` are refused with `kept_on_device`: those live on the device |
|
||||
| `GET /notifications` | Basic | This account's notifications |
|
||||
|
||||
@@ -39,6 +39,37 @@ counts only unspent codes, and those keep their issuer until spent.
|
||||
Both backfills are irreversible and each is its own migration. Nothing reads
|
||||
what they drop. `key_log` is untouched.
|
||||
|
||||
### Delivery-key digests and presence tokens, per device
|
||||
|
||||
`delivery_keys` held one digest per account and `roster_tags` one token set
|
||||
per account, while each device mints its own key and its own tokens. The
|
||||
second device to publish replaced the first: its contacts' sealed sends were
|
||||
refused (and the apps fell back to unsealed), and its contacts saw the account
|
||||
as offline. Production had 2 accounts with more than one device out of 15
|
||||
devices, 11 delivery-key rows and 15 roster-tag rows across 6 accounts.
|
||||
|
||||
New tables `device_delivery_keys` and `device_roster_tags`, keyed by account
|
||||
and device. The old tables are untouched and their rows keep working as
|
||||
account-wide rows, since which device published them was never recorded:
|
||||
|
||||
- delivery keys: every publish from a registered device lands in its own
|
||||
row. The account-wide row is accepted until every device on the account has
|
||||
one, then deleted. Both apps publish once and never again unless they rotate,
|
||||
so dropping it sooner would have refused sealed sends to devices that will
|
||||
not publish again. The descriptor's `features.perDeviceDigests` lets a new
|
||||
app publish once more.
|
||||
- roster tags: an app that sends `device_id` replaces only that device's set,
|
||||
and the account-wide set is deleted at the same time (it may be that
|
||||
device's older set, and keeping it would undo revocations). An app that sends
|
||||
no `device_id` replaces the account-wide set, as before.
|
||||
- the sealed check compares against every device's digest plus the
|
||||
account-wide one, padded to `FN_MAX_DEVICES` + 1 comparisons.
|
||||
- retiring a device (revoke, expiry, making room) deletes its rows from both
|
||||
new tables. Never `key_log`.
|
||||
|
||||
What a dump shows changes slightly: the per-device counts of presence tokens,
|
||||
where it used to show one per account.
|
||||
|
||||
### Message length: groundwork, nothing hidden yet
|
||||
|
||||
Pass 2's finding stands: a stored payload's length is exact. This change
|
||||
|
||||
Reference in New Issue
Block a user