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:
co-authored by
Claude Opus 4.8
parent
657e53693a
commit
2f720f82be
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user