Carry a signed wipe instruction, without being able to forge one

The other half of the wipe path (§6.26). The relay stores an opaque blob
signed by the account key — which it never sees — and hands it to the
device it names, on that device's own request.

🔴 This is why the route exists rather than the relay simply telling a
device it has been removed. Doing it that way handed an untrusted server
the power to destroy every user's local history at will, irreversibly
(flamenet-messenger-ios#60).

⚠️ Suppression is the only attack left, and it fails safe. A relay
withholding an instruction stops a wipe happening; it cannot cause one.
The failure mode is "the lost phone still has the messages", which is
where it started, rather than "everybody lost theirs".

⚠️ A device only ever learns about its OWN order. A route taking a device
id would let anybody enumerate which devices have been wiped, which is a
fact about somebody's life the relay has no business publishing.

⚠️ The order must name one of the caller's own devices. The signature
protects the far end regardless, but a relay accepting orders for other
people's devices would let anybody fill this table with blobs addressed
to strangers.

🔴 `Pending` encodes its optional by hand. The synthesised encoder uses
`encodeIfPresent`, so "nothing waiting" came back as `{}` — an absent key
rather than a null — and a client telling those apart by accident is a
client that wipes on a parse quirk. The test failed exactly as it should
have, and the fix is in the encoder rather than the assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 19:55:18 -04:00
co-authored by Claude Opus 4.8
parent 657e53693a
commit 2f720f82be
4 changed files with 288 additions and 1 deletions
+136
View File
@@ -0,0 +1,136 @@
import Fluent
import Foundation
import Vapor
/// A signed instruction for one device to erase what it holds (§6.26).
///
/// 🔴 **The relay is a courier here and nothing more.** The blob is signed by
/// the account key, which the relay never sees, so it cannot forge an
/// instruction — and that is the entire reason this exists rather than the
/// relay simply telling a device it has been removed. Doing it that way handed
/// an untrusted server the power to destroy every user's history at will
/// (flamenet-messenger-ios#60).
///
/// ⚠️ **Suppression is the only attack left, and it fails safe.** A relay that
/// withholds an instruction stops a wipe happening; it cannot cause one. That
/// asymmetry is the point — the failure mode is "the lost phone still has the
/// messages", which is where it started, rather than "everybody lost theirs".
final class DeviceWipeOrder: Model, @unchecked Sendable {
static let schema = "device_wipe_orders"
@ID(custom: "id", generatedBy: .database) var id: Int?
/// The device the instruction names. One row per device at most.
@Field(key: "device_id") var deviceID: String
/// The account that issued it, for the relay's own authorisation check.
///
/// ⚠️ Checked so one account cannot queue an instruction for another
/// account's device. That instruction would fail the signature at the far
/// end anyway, but a relay that accepted it would let anybody fill this
/// table with blobs addressed to strangers.
@Field(key: "user_id") var userID: Int
/// The signed instruction, verbatim. Opaque here.
@Field(key: "instruction") var instruction: String
@Field(key: "created_at") var createdAt: Int
init() {}
/// The largest blob accepted.
///
/// ⚠️ A bound, because this is attacker-influenced text on the relay's
/// disk. A signed instruction is a device id, a timestamp and a signature;
/// a kilobyte is generous.
static let maximumInstruction = 1_024
}
extension Routes {
/// §6.26 — queue a wipe for one of the caller's own devices.
func publishWipeOrder(_ request: Request, now: Int) async throws -> Response {
let body = request.body.data.map { Data(buffer: $0) } ?? Data()
let caller = try await authenticate(
request, body: body, path: "/e2e/wipe", now: now)
struct Order: Content {
let device: String
let instruction: String
}
let order = try request.content.decode(Order.self)
guard !order.device.isEmpty,
order.instruction.count <= DeviceWipeOrder.maximumInstruction,
!order.instruction.isEmpty
else { throw WireError.badRequest("Malformed wipe instruction.") }
// ⚠️ The device must belong to the caller. The signature is what
// actually protects the far end, but a relay that queued instructions
// for other people's devices would be a way to fill this table with
// blobs nobody asked for.
// ⚠️ `Device` is keyed by `device_id` as its ID, not a `deviceID`
// field — `find` rather than a filter on a property that does not exist.
guard let device = try await Device.find(order.device, on: request.db),
device.userID == caller.userID
else { throw WireError("not_your_device", "That device is not on this account.", 403) }
// One row per device: re-issuing replaces rather than accumulating.
// Two instructions for one device are the same instruction twice.
if let existing = try await DeviceWipeOrder.query(on: request.db)
.filter(\.$deviceID == order.device).first() {
existing.instruction = order.instruction
existing.createdAt = now
try await existing.save(on: request.db)
} else {
let row = DeviceWipeOrder()
row.deviceID = order.device
row.userID = caller.userID
row.instruction = order.instruction
row.createdAt = now
try await row.create(on: request.db)
}
return try .json(["ok": true])
}
/// §6.26 — the instruction waiting for the calling device, if any.
///
/// ⚠️ Answers for the CALLER'S OWN device only. A route that took a device
/// id would let anybody enumerate which devices have been wiped, which is a
/// fact about somebody's life that the relay has no business publishing.
func pendingWipeOrder(_ request: Request, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/wipe", now: now)
struct Pending: Content {
let instruction: String?
/// 🔴 **Hand-written, because the synthesised one omits the key.**
/// `Codable`'s generated encoder uses `encodeIfPresent` for an
/// optional, so "nothing waiting" came back as `{}` — an absent
/// key rather than a null. A client telling those two apart by
/// accident is a client that wipes on a parse quirk, and the test
/// for this failed exactly as it should have.
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(instruction, forKey: .instruction)
}
}
let row = try await DeviceWipeOrder.query(on: request.db)
.filter(\.$deviceID == caller.deviceID).first()
return try .json(Pending(instruction: row?.instruction))
}
}
struct CreateDeviceWipeOrder: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(DeviceWipeOrder.schema)
.field("id", .int, .identifier(auto: true))
.field("device_id", .string, .required)
.field("user_id", .int, .required)
.field("instruction", .string, .required)
.field("created_at", .int, .required)
// One instruction per device — re-issuing replaces it.
.unique(on: "device_id")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(DeviceWipeOrder.schema).delete()
}
}
+10
View File
@@ -912,6 +912,16 @@ func register(routes r: Routes, on app: Application) throws {
return try await r.logDevice(req, deviceID: id)
}
// §6.26 — a signed instruction for one device to erase itself. The
// relay is a courier: it cannot forge one, and withholding one only
// stops a wipe rather than causing one.
e2e.post("wipe") { req in
try await r.publishWipeOrder(req, now: unixNow())
}
e2e.get("wipe") { req in
try await r.pendingWipeOrder(req, now: unixNow())
}
// §8.10
e2e.get("turn") { req in
try await r.turnCredentials(req, now: unixNow())
+1 -1
View File
@@ -608,6 +608,6 @@ enum RelayMigrations {
AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(),
AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(),
BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(),
SealGroupRosters()]
SealGroupRosters(), CreateDeviceWipeOrder()]
}
}
@@ -0,0 +1,141 @@
import Fluent
import Foundation
import Testing
import Vapor
@testable import FlamenetRelay
/// Carrying a signed wipe instruction (§6.26).
///
/// 🔴 **The relay is a courier and nothing more.** The blob is signed by the
/// account key, which the relay never sees, so it cannot forge one. That is the
/// whole reason this route exists rather than the relay simply telling a device
/// it has been removed — doing it that way handed an untrusted server the power
/// to destroy every user's history at will.
@Suite("device wipe orders")
struct DeviceWipeOrderTests {
private let now = 1_700_000_000
private func publish(_ h: RelayHarness, as d: RelayHarness.TestDevice,
device: String, instruction: String = "signed-blob") async throws -> Response {
let body = Data(#"{"device":"\#(device)","instruction":"\#(instruction)"}"#.utf8)
let req = h.request(.POST, "/e2e/wipe", as: d, body: body, now: now)
return try await h.routes.publishWipeOrder(req, now: now)
}
@Test("an order for your own device is stored")
func storedForYourOwnDevice() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
let response = try await publish(h, as: d, device: "aaaa-0001")
#expect(response.status == .ok)
#expect(try await DeviceWipeOrder.query(on: h.db).count() == 1)
}
}
/// ⚠️ The signature protects the far end, but a relay accepting orders for
/// other people's devices would let anybody fill this table with blobs
/// addressed to strangers.
@Test("an order for somebody else's device is refused")
func refusedForAnotherAccountsDevice() async throws {
try await withRelay { h in
let mine = h.device(user: 1, id: "aaaa-0001")
let theirs = h.device(user: 2, id: "bbbb-0002")
_ = try await h.register(mine, on: h.db)
_ = try await h.register(theirs, on: h.db)
var code = ""
do { _ = try await publish(h, as: mine, device: "bbbb-0002") }
catch let e as WireError { code = e.code }
#expect(code == "not_your_device")
#expect(try await DeviceWipeOrder.query(on: h.db).count() == 0)
}
}
/// ⚠️ One row per device. Two instructions for one device are the same
/// instruction twice; accumulating them would make "is there one waiting"
/// a question with a growing answer.
@Test("re-issuing replaces rather than accumulating")
func reissuingReplaces() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
_ = try await publish(h, as: d, device: "aaaa-0001", instruction: "first")
_ = try await publish(h, as: d, device: "aaaa-0001", instruction: "second")
let rows = try await DeviceWipeOrder.query(on: h.db).all()
#expect(rows.count == 1)
#expect(rows[0].instruction == "second")
}
}
@Test("a device fetches the order waiting for it")
func fetchesItsOwnOrder() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
_ = try await publish(h, as: d, device: "aaaa-0001", instruction: "blob")
let req = h.request(.GET, "/e2e/wipe", as: d, body: Data(), now: now)
let response = try await h.routes.pendingWipeOrder(req, now: now)
#expect(response.body.string?.contains("blob") == true)
}
}
/// ⚠️ Nothing waiting must be a clean answer, not an error and not an
/// absent key — a client distinguishing those by accident is a client that
/// wipes on a parse quirk.
@Test("no order waiting answers null rather than failing")
func nothingWaitingIsNull() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
let req = h.request(.GET, "/e2e/wipe", as: d, body: Data(), now: now)
let response = try await h.routes.pendingWipeOrder(req, now: now)
#expect(response.status == .ok)
#expect(response.body.string?.contains("instruction") == true)
}
}
/// 🔴 **A device only ever learns about its own order.** A route that took
/// a device id would let anybody enumerate which devices have been wiped,
/// which is a fact about somebody's life the relay has no business
/// publishing.
@Test("one device cannot fetch another's order")
func cannotFetchAnothersOrder() async throws {
try await withRelay { h in
let mine = h.device(user: 1, id: "aaaa-0001")
let other = h.device(user: 1, id: "cccc-0003")
_ = try await h.register(mine, on: h.db)
_ = try await h.register(other, on: h.db)
_ = try await publish(h, as: mine, device: "aaaa-0001", instruction: "secret")
let req = h.request(.GET, "/e2e/wipe", as: other, body: Data(), now: now)
let response = try await h.routes.pendingWipeOrder(req, now: now)
#expect(response.body.string?.contains("secret") != true)
}
}
/// ⚠️ Bounded: attacker-influenced text on the relay's disk.
@Test("an oversized instruction is refused")
func oversizedIsRefused() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
let huge = String(repeating: "a", count: DeviceWipeOrder.maximumInstruction + 1)
await #expect(throws: WireError.self) {
_ = try await publish(h, as: d, device: "aaaa-0001", instruction: huge)
}
}
}
@Test("an empty instruction is refused")
func emptyIsRefused() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
await #expect(throws: WireError.self) {
_ = try await publish(h, as: d, device: "aaaa-0001", instruction: "")
}
}
}
}