diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index b1736ef..78a7949 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: 41e0e20cc4e1f529 -->

Configuring a relay

@@ -342,6 +342,39 @@ limit. +

Erasing a lost device

+

Off. The relay does not carry instructions for a device to erase +itself.

+ + + + + + + + + + + + + + + +
VariableDefaultWhat it does
FN_DEVICE_WIPEfalseWhen on, the relay accepts a signed erase instruction for one device +and hands it over when that device next asks. Accepts true, +1 or yes.
+

Off because the feature is not finished end to end, not because the +route is dangerous. No client in the field can honour a wipe โ€” the +Android build released on 2026-08-31 (v8) never asks for one โ€” and the +issuing side still needs the passphrase re-entered before an order can +be signed. A half-built remote erase that is on by default looks like a +guarantee and is not one yet.

+

The relay is only ever a courier. The instruction is signed by the +account key, which the relay does not hold, so it cannot forge one; and +withholding one stops a wipe rather than causing one. When off, both +routes refuse with wipe_not_enabled rather than a 404, so a +client can tell a server that has this switched off from one too old to +know what it is.

Retention

The relay purges on its own timer, in-process. Nothing external is needed.

diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index 8b7eddf..e169a2f 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -131,6 +131,22 @@ struct Config: Sendable { /// identifier first. Off leaves the head and the proofs, which is the part /// clients depend on; only per-subject enumeration goes away. let keyLogSubjectQueries: Bool + + /// Whether this relay will carry a signed instruction for a device to + /// erase itself (ยง6.26). + /// + /// ๐Ÿ”ด **Off by default, because the feature is not finished end to end.** + /// No client in the field can honour a wipe โ€” the Android build is v8, + /// which never polls for one โ€” and the issuing side still needs the + /// passphrase re-entered to sign an order. A half-built remote wipe that is + /// on by default is exactly the thing that should wait: it looks like a + /// guarantee and is not one yet. + /// + /// The relay is only ever a courier here. It cannot forge an order, and + /// withholding one stops a wipe rather than causing one โ€” so the risk of + /// carrying it is small. The reason to keep it off is honesty about what + /// works, not fear of the route. + let deviceWipeEnabled: Bool let attachmentBytesPerHour: Int /// The rolling 30-day per-account upload budget (C3). The hourly limits /// above stop bursts; this stops a steady drip from quietly owning the @@ -262,6 +278,12 @@ struct Config: Sendable { let raw = (env["FN_KEY_LOG_SUBJECT_QUERIES"] ?? "").lowercased() return !(raw == "false" || raw == "0" || raw == "no" || raw == "off") }(), + + deviceWipeEnabled: { + // Opt-in only: anything that is not an explicit yes is off. + let raw = (env["FN_DEVICE_WIPE"] ?? "").lowercased() + return raw == "true" || raw == "1" || raw == "yes" + }(), attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024), attachmentBytesPerMonth: int("FN_ATTACHMENT_BYTES_PER_MONTH", 500 * 1024 * 1024), attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30), diff --git a/Sources/FlamenetRelay/DeviceWipeOrder.swift b/Sources/FlamenetRelay/DeviceWipeOrder.swift new file mode 100644 index 0000000..e8b353b --- /dev/null +++ b/Sources/FlamenetRelay/DeviceWipeOrder.swift @@ -0,0 +1,148 @@ +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 { + + /// โš ๏ธ Refused with a code rather than a 404, so a client can tell a relay + /// that has this switched off from one too old to have the route at all. + /// One is a setting somebody chose; the other means stop asking. + func requireDeviceWipeEnabled() throws { + guard config.deviceWipeEnabled else { + throw WireError("wipe_not_enabled", + "This server does not carry remote erase instructions.", 501) + } + } + + /// ยง6.26 โ€” queue a wipe for one of the caller's own devices. + func publishWipeOrder(_ request: Request, now: Int) async throws -> Response { + try requireDeviceWipeEnabled() + 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 { + try requireDeviceWipeEnabled() + 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() + } +} diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 8847b88..06b7421 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -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()) diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 6f51ec3..528b492 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -608,6 +608,6 @@ enum RelayMigrations { AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(), AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(), BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(), - SealGroupRosters()] + SealGroupRosters(), CreateDeviceWipeOrder()] } } diff --git a/Tests/FlamenetRelayTests/DeviceWipeOrderTests.swift b/Tests/FlamenetRelayTests/DeviceWipeOrderTests.swift new file mode 100644 index 0000000..74172e3 --- /dev/null +++ b/Tests/FlamenetRelayTests/DeviceWipeOrderTests.swift @@ -0,0 +1,208 @@ +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 { + + /// ๐Ÿ”ด Carrying wipe orders is off by default, so every case here has to + /// switch it on. That is the point of the default, not an inconvenience: + /// nothing below would run on a relay somebody had not deliberately + /// configured for this. + private static let enabled = ["FN_DEVICE_WIPE": "true"] + + 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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(env: Self.enabled) { 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: "") + } + } + } + + // MARK: - The switch + + /// ๐Ÿ”ด Off with nothing configured, and off is what a relay somebody has not + /// deliberately set up must be. + /// + /// No client in the field can honour a wipe โ€” the Android build is v8, + /// which never polls for one โ€” and the issuing side still needs the + /// passphrase re-entered to sign an order. On by default would advertise a + /// guarantee that does not exist yet. + @Test("with nothing configured, the relay does not carry wipe orders") + func offByDefault() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + do { + _ = try await self.publish(h, as: d, device: "aaaa-0001") + Issue.record("a wipe order was accepted on a relay with the feature off") + } catch let error as WireError { + #expect(error.code == "wipe_not_enabled") + #expect(error.data.status == 501) + } + } + } + + /// Reading is gated too, not only writing. A relay that will not carry an + /// order must not answer questions about one either. + @Test("polling for an order is refused the same way") + func pollingIsGatedToo() 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: self.now) + do { + _ = try await h.routes.pendingWipeOrder(req, now: self.now) + Issue.record("a relay with the feature off answered a wipe poll") + } catch let error as WireError { + #expect(error.code == "wipe_not_enabled") + } + } + } + + /// Only an explicit yes turns it on. + @Test("only an explicit yes enables carrying wipe orders") + func onlyExplicitYesEnables() throws { + func flag(_ v: String?) throws -> Bool { + var env = [ + "FN_ISSUER_PUBLIC_KEY": Curve25519.Signing.PrivateKey() + .publicKey.rawRepresentation.base64EncodedString(), + "FN_RELAY_AUDIENCE": "test.relay", + ] + if let v { env["FN_DEVICE_WIPE"] = v } + return try Config.fromEnvironment(env).deviceWipeEnabled + } + for off in [nil, "", "false", "no", "0", "off", "ture"] { + #expect(try flag(off) == false, "\(off ?? "unset") should leave it off") + } + for on in ["true", "TRUE", "1", "yes"] { + #expect(try flag(on) == true, "\(on) should turn it on") + } + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 40f277b..be14639 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -130,6 +130,18 @@ Defaults are sane; raise them only with a reason. | `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | | | `FN_ATTACHMENT_MAX_BYTES` | `10 MiB` | Per blob. | +## Erasing a lost device + +Off. The relay does not carry instructions for a device to erase itself. + +| Variable | Default | What it does | +|---|---|---| +| `FN_DEVICE_WIPE` | `false` | When on, the relay accepts a signed erase instruction for one device and hands it over when that device next asks. Accepts `true`, `1` or `yes`. | + +Off because the feature is not finished end to end, not because the route is dangerous. No client in the field can honour a wipe โ€” the Android build released on 2026-08-31 (v8) never asks for one โ€” and the issuing side still needs the passphrase re-entered before an order can be signed. A half-built remote erase that is on by default looks like a guarantee and is not one yet. + +The relay is only ever a courier. The instruction is signed by the account key, which the relay does not hold, so it cannot forge one; and withholding one stops a wipe rather than causing one. When off, both routes refuse with `wipe_not_enabled` rather than a 404, so a client can tell a server that has this switched off from one too old to know what it is. + ## Retention The relay purges on its own timer, in-process. Nothing external is needed.