Let an operator switch off per-subject log queries
FN_KEY_LOG_SUBJECT_QUERIES, on by default. A substitution cannot be detected in a log nobody may read, and a monitor is by definition somebody else — so an unauthenticated read is the feature rather than a leak in it, and off by default would quietly undo §19. But the cost the docs already state is real, and an operator is allowed to weigh it differently: anyone holding an fnid can see how many devices that account has published and roughly when. Off removes only enumeration. The head and the proofs — the part clients depend on — keep working, and the route refuses with `subject_queries_disabled` rather than a 404, so a monitor can tell "this relay does not answer that" from "there is no log here". A relay that stops answering was never an all-clear. Only an explicit no disables it, so a typo cannot quietly make a relay unmonitorable. 480 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: 00f2d337316548ad -->
|
||||
source-sha256: 4bbbe5461d19a20a -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="configuring-a-relay">Configuring a relay</h1>
|
||||
@@ -431,6 +431,51 @@ looking wrong. With this set, the publishable key stays the
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="watching-the-key-log-relay32">Watching the key log
|
||||
(relay#("#")32)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>What it does</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_KEY_LOG_SUBJECT_QUERIES</code></td>
|
||||
<td>on</td>
|
||||
<td>Set to <code>false</code>, <code>0</code>, <code>no</code> or
|
||||
<code>off</code> to stop listing a single account's entries. The log
|
||||
head and the inclusion proofs still work — only per-subject enumeration
|
||||
goes away, and the route then refuses with
|
||||
<code>subject_queries_disabled</code> rather than a 404, so a monitor
|
||||
can tell "this relay does not answer that" from "there is no log
|
||||
here".</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>GET /e2e/log/subject?subject=<fnid></code> lists every
|
||||
entry published under one account — publications and retirements, with
|
||||
the device each names. It is unauthenticated, like the rest of §19: a
|
||||
log only its own users can read is not a transparency log, and a monitor
|
||||
is by definition somebody else.</p>
|
||||
<p>⚠️ <strong>That has a cost, and it is stated here rather than left to
|
||||
be discovered.</strong> Anyone holding somebody's fnid — the address
|
||||
they hand to contacts — can see how many devices they have published and
|
||||
roughly when. This is inherent to key transparency rather than an
|
||||
oversight: a substitution cannot be detected in a log nobody may read.
|
||||
What it is not is a directory; you need the identifier first.</p>
|
||||
<p><code>Scripts/watch-key-log.py</code> is a self-hostable monitor,
|
||||
forty lines of stdlib. 🔴 Run it somewhere you control — a hosted
|
||||
service everybody is enrolled in would learn which accounts are worth
|
||||
watching, which is a list nobody should be assembling.</p>
|
||||
<p>⚠️ It reports what the relay <strong>serves</strong>. A relay showing
|
||||
one log to you and another to your contact is the fork §19.4's head
|
||||
gossip exists to catch, and no single watcher sees it; witness
|
||||
co-signing (relay#("#")23) is the answer to that and does not exist yet. And
|
||||
a relay that stops answering is not an all-clear — the script says
|
||||
"could not reach", never "no changes".</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -117,6 +117,20 @@ struct Config: Sendable {
|
||||
/// per-address limit — for an operator who would rather admit an abuser
|
||||
/// than risk refusing a campus.
|
||||
let signupMaxPerNetworkPerHour: Int
|
||||
|
||||
/// Whether `GET /e2e/log/subject` will list one account's log entries.
|
||||
///
|
||||
/// **On by default.** A substitution cannot be detected in a log nobody may
|
||||
/// read, and a monitor is by definition somebody else — so an unauthenticated
|
||||
/// read is the feature, not a leak in it.
|
||||
///
|
||||
/// ⚠️ It does have a cost, and an operator is allowed to weigh it
|
||||
/// differently: anyone holding an fnid can see how many devices that account
|
||||
/// has published and roughly when. That is inherent to key transparency
|
||||
/// rather than an oversight, and it is not a directory — you need the
|
||||
/// 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
|
||||
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
|
||||
@@ -242,6 +256,12 @@ struct Config: Sendable {
|
||||
signupMaxPerHour: int("FN_SIGNUP_MAX_PER_HOUR", AccountRoutes.signupMaxPerHourDefault),
|
||||
signupMaxPerNetworkPerHour:
|
||||
int("FN_SIGNUP_MAX_PER_NETWORK", AccountRoutes.signupMaxPerNetworkDefault),
|
||||
|
||||
keyLogSubjectQueries: {
|
||||
// On unless explicitly switched off.
|
||||
let raw = (env["FN_KEY_LOG_SUBJECT_QUERIES"] ?? "").lowercased()
|
||||
return !(raw == "false" || raw == "0" || raw == "no" || raw == "off")
|
||||
}(),
|
||||
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),
|
||||
|
||||
@@ -148,6 +148,13 @@ extension Routes {
|
||||
/// substitution cannot be detected in a log nobody may read. It is stated
|
||||
/// in the docs rather than left to be discovered.
|
||||
func logSubjectEntries(_ request: Request) async throws -> Response {
|
||||
// ⚠️ A code, not a 404: a relay that has enumeration switched off still
|
||||
// serves the log head and the proofs, and a monitor should say "this
|
||||
// relay does not answer that" rather than "there is no log here".
|
||||
guard config.keyLogSubjectQueries else {
|
||||
throw WireError("subject_queries_disabled",
|
||||
"This server does not list a single account's log entries.", 501)
|
||||
}
|
||||
guard let subject = request.query[String.self, at: "subject"],
|
||||
!subject.isEmpty, subject.count <= 256
|
||||
else { throw WireError.badRequest("Missing or malformed subject.") }
|
||||
|
||||
@@ -117,4 +117,54 @@ struct LogSubjectTests {
|
||||
#expect(try await fetch(h, subject: "fnidA").contains("d1"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The switch
|
||||
|
||||
/// On with nothing configured: a substitution cannot be detected in a log
|
||||
/// nobody may read, so the default has to be readable.
|
||||
@Test("subject queries answer with nothing configured")
|
||||
func onByDefault() async throws {
|
||||
try await withRelay { h in
|
||||
let req = h.anonymousRequest(.GET, "/e2e/log/subject?subject=local:test.relay/1")
|
||||
_ = try await h.routes.logSubjectEntries(req)
|
||||
}
|
||||
}
|
||||
|
||||
/// An operator may weigh the enumeration cost differently, and off says so
|
||||
/// with a code rather than pretending the log is not there — the head and
|
||||
/// the proofs still work, and a monitor should be able to tell.
|
||||
@Test("switched off, enumeration is refused but the log still exists")
|
||||
func offRefusesWithACode() async throws {
|
||||
try await withRelay(env: ["FN_KEY_LOG_SUBJECT_QUERIES": "false"]) { h in
|
||||
let req = h.anonymousRequest(.GET, "/e2e/log/subject?subject=local:test.relay/1")
|
||||
do {
|
||||
_ = try await h.routes.logSubjectEntries(req)
|
||||
Issue.record("a relay with enumeration off answered a subject query")
|
||||
} catch let error as WireError {
|
||||
#expect(error.code == "subject_queries_disabled")
|
||||
#expect(error.data.status == 501)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only an explicit no turns it off — a typo must not quietly stop a relay
|
||||
/// being monitorable.
|
||||
@Test("only an explicit no disables enumeration")
|
||||
func onlyExplicitNoDisables() 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_KEY_LOG_SUBJECT_QUERIES"] = v }
|
||||
return try Config.fromEnvironment(env).keyLogSubjectQueries
|
||||
}
|
||||
for on in [nil, "", "true", "yes", "1", "flase"] {
|
||||
#expect(try flag(on) == true, "\(on ?? "unset") should leave it on")
|
||||
}
|
||||
for off in ["false", "FALSE", "0", "no", "off"] {
|
||||
#expect(try flag(off) == false, "\(off) should switch it off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ All optional. Each is absent rather than half-enabled when unconfigured.
|
||||
|
||||
## Watching the key log (relay#32)
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `FN_KEY_LOG_SUBJECT_QUERIES` | on | Set to `false`, `0`, `no` or `off` to stop listing a single account's entries. The log head and the inclusion proofs still work — only per-subject enumeration goes away, and the route then refuses with `subject_queries_disabled` rather than a 404, so a monitor can tell "this relay does not answer that" from "there is no log here". |
|
||||
|
||||
`GET /e2e/log/subject?subject=<fnid>` lists every entry published under one
|
||||
account — publications and retirements, with the device each names. It is
|
||||
unauthenticated, like the rest of §19: a log only its own users can read is not a
|
||||
|
||||
Reference in New Issue
Block a user