Carry a signed wipe instruction, without being able to forge one #89
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
|
||||
Edits made HERE are deleted the next time that script runs.
|
||||
Change the markdown source and re-run it.
|
||||
source-sha256: 4bbbe5461d19a20a -->
|
||||
source-sha256: 41e0e20cc4e1f529 -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="configuring-a-relay">Configuring a relay</h1>
|
||||
@@ -342,6 +342,39 @@ limit.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="erasing-a-lost-device">Erasing a lost device</h2>
|
||||
<p>Off. The relay does not carry instructions for a device to erase
|
||||
itself.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>What it does</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_DEVICE_WIPE</code></td>
|
||||
<td><code>false</code></td>
|
||||
<td>When on, the relay accepts a signed erase instruction for one device
|
||||
and hands it over when that device next asks. Accepts <code>true</code>,
|
||||
<code>1</code> or <code>yes</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>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.</p>
|
||||
<p>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 <code>wipe_not_enabled</code> 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.</p>
|
||||
<h2 id="retention">Retention</h2>
|
||||
<p>The relay purges on its own timer, in-process. Nothing external is
|
||||
needed.</p>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user