Carry wipe orders only when switched on, and default it off

FN_DEVICE_WIPE, off unless explicitly set. 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 — 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.

Both routes are gated, reading as well as writing: a relay that will not
carry an order must not answer questions about one either. Refused with
`wipe_not_enabled` (501) rather than a 404, so a client can tell a server
that has this off from one too old to have the route.

The eight existing cases now run against a relay with it deliberately
enabled, which is the default doing its job rather than an inconvenience:
none of them would run on a server nobody had configured for this.

Added: off with nothing configured, polling gated the same way, and only an
explicit yes enabling it. 481 pass.
This commit is contained in:
Paul Hitt
2026-09-02 19:56:18 -04:00
parent 2f720f82be
commit 4a6bcb07be
5 changed files with 155 additions and 9 deletions
+34 -1
View File
@@ -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>
+22
View File
@@ -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),
@@ -44,8 +44,19 @@ final class DeviceWipeOrder: Model, @unchecked Sendable {
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)
@@ -94,6 +105,7 @@ extension Routes {
/// 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)
@@ -15,6 +15,12 @@ import Vapor
@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,
@@ -26,7 +32,7 @@ struct DeviceWipeOrderTests {
@Test("an order for your own device is stored")
func storedForYourOwnDevice() async throws {
try await withRelay { h in
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")
@@ -40,7 +46,7 @@ struct DeviceWipeOrderTests {
/// addressed to strangers.
@Test("an order for somebody else's device is refused")
func refusedForAnotherAccountsDevice() async throws {
try await withRelay { h in
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)
@@ -58,7 +64,7 @@ struct DeviceWipeOrderTests {
/// a question with a growing answer.
@Test("re-issuing replaces rather than accumulating")
func reissuingReplaces() async throws {
try await withRelay { h in
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")
@@ -71,7 +77,7 @@ struct DeviceWipeOrderTests {
@Test("a device fetches the order waiting for it")
func fetchesItsOwnOrder() async throws {
try await withRelay { h in
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")
@@ -86,7 +92,7 @@ struct DeviceWipeOrderTests {
/// wipes on a parse quirk.
@Test("no order waiting answers null rather than failing")
func nothingWaitingIsNull() async throws {
try await withRelay { h in
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)
@@ -102,7 +108,7 @@ struct DeviceWipeOrderTests {
/// publishing.
@Test("one device cannot fetch another's order")
func cannotFetchAnothersOrder() async throws {
try await withRelay { h in
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)
@@ -118,7 +124,7 @@ struct DeviceWipeOrderTests {
/// ⚠️ Bounded: attacker-influenced text on the relay's disk.
@Test("an oversized instruction is refused")
func oversizedIsRefused() async throws {
try await withRelay { h in
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)
@@ -130,7 +136,7 @@ struct DeviceWipeOrderTests {
@Test("an empty instruction is refused")
func emptyIsRefused() async throws {
try await withRelay { h in
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) {
@@ -138,4 +144,65 @@ struct DeviceWipeOrderTests {
}
}
}
// 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")
}
}
}
+12
View File
@@ -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.