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:
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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