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:
Paul Hitt
2026-09-02 19:55:08 -04:00
parent 3a01cf114a
commit 5f552c5d07
5 changed files with 127 additions and 1 deletions
+46 -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: 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=&lt;fnid&gt;</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>
+20
View File
@@ -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),
+7
View File
@@ -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")
}
}
}
+4
View File
@@ -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