Let somebody watch the key log for their own account

🔴 Without a by-subject lookup a monitor cannot monitor. Watching an
account means noticing a device published under it that its owner did not
add — and the only lookup that existed was by device id, which requires
already knowing the device the monitor is trying to discover. The
`subject` column was there; nothing exposed it.

⚠️ A query parameter rather than a path segment. `KeyLog.logSubject`
falls back to `local:<audience>/<userID>` for an account with no fnid,
and that contains a slash — a path route would have silently 404'd for
exactly the accounts least able to notice.

⚠️ The Merkle index stays a POSITION in the whole log, not a per-subject
number. A monitor asking for an inclusion proof at the wrong index gets a
leaf that does not verify, which reads as tampering when it is
arithmetic. There is a test with an unrelated entry ahead of the subject's
so a per-subject numbering fails it.

⚠️ Unauthenticated, like the rest of §19, and the cost is written into
the docs rather than left to be discovered: anyone holding somebody's
fnid can see how many devices they have published and when. That is
inherent to key transparency — a substitution cannot be detected in a log
nobody may read — and it is not a directory, since you need the
identifier first.

`Scripts/watch-key-log.py` is the monitor: stdlib only, forty lines, so
it can be read and run by the person it protects. A hosted service
everybody was enrolled in would learn which accounts are worth watching,
which is a list nobody should be assembling.

⚠️ It says what it cannot see. A relay showing one log to one reader and
a different one to another is the fork §19.4's gossip exists to catch,
and no single watcher sees it — that is relay#23, which needs witnesses
nobody runs yet. And a relay that stops answering reports as "could not
reach", never as "no changes": the first run is a baseline rather than a
report, because treating everything as new the first time trains whoever
runs it to ignore it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 19:54:11 -04:00
co-authored by Claude Opus 4.8
parent 83f0a02920
commit 3a01cf114a
5 changed files with 291 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Watch a relay's key log for devices published under one account (relay#32).
watch-key-log.py --relay https://relay.example --subject <fnid> [--state file]
Prints a line whenever a device is published or retired under that subject, and
exits non-zero if anything changed since the last run — so cron plus a mail alias
is a complete monitor.
🔴 **Run this 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. That is why this is forty lines of stdlib and no dependencies: the
point is that you can read it and run it yourself.
⚠️ **What it can and cannot tell you.** It reports what the relay SERVES. A relay
that shows one log to you and another to your contact is the fork §19.4's head
gossip exists to catch, and this script cannot see it alone — relay#23's
co-signing witnesses are the answer to that, and they do not exist yet. What this
does catch is the ordinary case: a device appearing under your account that you
did not add.
⚠️ A relay that simply stops answering is not proof of anything, and this says
"could not reach" rather than "no changes". Silence is not an all-clear.
"""
import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
def fetch(relay, subject):
url = (relay.rstrip("/") + "/e2e/log/subject?subject="
+ urllib.parse.quote(subject, safe=""))
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--relay", required=True)
ap.add_argument("--subject", required=True,
help="the account's fnid, or local:<audience>/<user id>")
ap.add_argument("--state", default="key-log-watch.json")
args = ap.parse_args()
try:
answer = fetch(args.relay, args.subject)
except Exception as e: # noqa: BLE001 — any failure is the same answer
# ⚠️ Not "no changes". A relay that has stopped answering is worth
# noticing and is also just an outage; saying which would be inventing.
print(f"could not reach the log: {e}", file=sys.stderr)
return 2
entries = answer.get("entries", [])
seen = {f"{e['index']}:{e['kind']}:{e['device_id']}" for e in entries}
known = set()
if os.path.exists(args.state):
with open(args.state) as f:
known = set(json.load(f).get("seen", []))
fresh = sorted(seen - known, key=lambda k: int(k.split(":", 1)[0]))
for key in fresh:
index, kind, device = key.split(":", 2)
at = next((e.get("at") for e in entries if e["index"] == int(index)), None)
print(f"{kind:<8} {device} index {index} {at or 'no timestamp'}")
with open(args.state, "w") as f:
json.dump({"subject": args.subject, "seen": sorted(seen)}, f, indent=1)
if not known:
# ⚠️ The first run is a baseline, not a report. Treating everything as
# new the first time would train whoever runs this to ignore it.
print(f"baseline: {len(seen)} entries recorded, nothing to compare against yet",
file=sys.stderr)
return 0
return 1 if fresh else 0
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -901,6 +901,10 @@ func register(routes r: Routes, on app: Application) throws {
e2e.get("log", "head") { req in try await r.logHead(req) }
e2e.get("log", "inclusion") { req in try await r.logInclusion(req) }
e2e.get("log", "consistency") { req in try await r.logConsistency(req) }
// relay#32 — every entry under one subject, so a monitor can watch an
// account for devices its owner did not add. A query parameter, not a
// path segment: a subject may contain a slash.
e2e.get("log", "subject") { req in try await r.logSubjectEntries(req) }
e2e.get("log", "device", ":device_id") { req in
guard let id = req.parameters.get("device_id") else {
throw WireError.badRequest("Malformed device id.")
+59
View File
@@ -127,4 +127,63 @@ extension Routes {
at: row.createdAt.map { ISO8601DateFormatter().string(from: $0) })
}))
}
/// §19 — every entry published under one subject (relay#32).
///
/// 🔴 **Without this a monitor cannot monitor.** Watching an account means
/// noticing a device published under it that its owner did not add — and
/// the only lookup that existed was by device id, which requires already
/// knowing the device the monitor is trying to discover. The `subject`
/// column was there; nothing exposed it.
///
/// ⚠️ **A query parameter, not a path segment.** `KeyLog.logSubject` falls
/// back to `local:<audience>/<userID>` for an account with no fnid, and
/// that contains a slash — a path route would silently 404 for exactly the
/// accounts least able to notice.
///
/// ⚠️ **Unauthenticated, like the rest of §19, and that has a cost worth
/// naming.** Anyone holding somebody's fnid — which is the address they
/// hand to contacts — can see how many devices they have published and
/// when. That is inherent to key transparency rather than a slip: a
/// 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 {
guard let subject = request.query[String.self, at: "subject"],
!subject.isEmpty, subject.count <= 256
else { throw WireError.badRequest("Missing or malformed subject.") }
// Same ordering rule as `logDevice`: the Merkle index is a POSITION in
// the tree, not a row id.
let ordered = try await LogEntry.query(on: request.db)
.sort(\.$id, .ascending).all()
var indexByRow: [Int: Int] = [:]
for (position, row) in ordered.enumerated() {
if let id = row.id { indexByRow[id] = position }
}
let rows = ordered.filter { $0.subject == subject }
return try .json(LogSubjectResponse(
subject: subject,
size: ordered.count,
entries: rows.compactMap { row in
guard let id = row.id, let index = indexByRow[id] else { return nil }
return .init(index: index, kind: row.kind, device_id: row.deviceID,
leaf: row.leafHash,
at: row.createdAt.map { ISO8601DateFormatter().string(from: $0) })
}))
}
}
struct LogSubjectResponse: Content {
let subject: String
let size: Int
let entries: [Entry]
struct Entry: Content {
let index: Int
let kind: String
/// Named here, unlike the by-device route, because a monitor's whole
/// job is learning which devices exist.
let device_id: String
let leaf: String
let at: String?
}
}
@@ -0,0 +1,120 @@
import Fluent
import Foundation
import Testing
import Vapor
@testable import FlamenetRelay
/// Watching the key log for one account (relay#32).
///
/// 🔴 **Without a by-subject lookup a monitor cannot monitor.** Watching an
/// account means noticing a device published under it that its owner did not
/// add — and the only lookup that existed was by device id, which requires
/// already knowing the device the monitor is trying to discover.
@Suite("key log by subject")
struct LogSubjectTests {
private func entry(_ db: any Database, subject: String, device: String,
kind: String, user: Int = 1) async throws {
let row = LogEntry()
row.leafHash = Data("\(subject)|\(device)|\(kind)".utf8).base64EncodedString()
row.userID = user
row.subject = subject
row.deviceID = device
row.kind = kind
try await row.create(on: db)
}
private func fetch(_ h: RelayHarness, subject: String) async throws -> String {
let escaped = subject.addingPercentEncoding(
withAllowedCharacters: .alphanumerics) ?? subject
let req = h.anonymousRequest(
.GET, "/e2e/log/subject?subject=" + escaped, body: Data())
let response = try await h.routes.logSubjectEntries(req)
return response.body.string ?? ""
}
@Test("every entry under one subject comes back")
func entriesForASubject() async throws {
try await withRelay { h in
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish")
try await entry(h.db, subject: "fnidA", device: "d2", kind: "publish")
try await entry(h.db, subject: "fnidB", device: "d3", kind: "publish")
let body = try await fetch(h, subject: "fnidA")
#expect(body.contains("d1"))
#expect(body.contains("d2"))
#expect(!body.contains("d3"))
}
}
/// 🔴 The device id is named, unlike the by-device route — a monitor's
/// whole job is learning which devices exist.
@Test("the answer names the devices")
func namesDevices() async throws {
try await withRelay { h in
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish")
#expect(try await fetch(h, subject: "fnidA").contains("device_id"))
}
}
/// A revocation is exactly what a monitor is watching for.
@Test("revocations appear alongside publications")
func revocationsAppear() async throws {
try await withRelay { h in
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish")
try await entry(h.db, subject: "fnidA", device: "d1", kind: "revoke")
let body = try await fetch(h, subject: "fnidA")
#expect(body.contains("publish"))
#expect(body.contains("revoke"))
}
}
/// ⚠️ **The Merkle index is a POSITION in the tree**, not a row id — the
/// same rule `logDevice` documents. A monitor asking for an inclusion proof
/// at the wrong index gets a leaf that does not verify, which reads as
/// tampering when it is arithmetic.
@Test("indices are positions in the whole log")
func indicesArePositions() async throws {
try await withRelay { h in
try await entry(h.db, subject: "other", device: "d0", kind: "publish")
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish")
let body = try await fetch(h, subject: "fnidA")
// The entry is second in the whole log, so its index is 1 — not 0,
// which is what a per-subject numbering would have produced.
#expect(body.contains("\"index\":1"))
#expect(body.contains("\"size\":2"))
}
}
/// A subject nobody has published under is an empty answer, not an error —
/// a monitor starting up must not read "no such account" as an alarm.
@Test("an unknown subject answers empty")
func unknownSubjectIsEmpty() async throws {
try await withRelay { h in
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish")
let body = try await fetch(h, subject: "nobody")
#expect(body.contains("\"entries\":[]"))
}
}
@Test("a missing subject is refused")
func missingSubjectRefused() async throws {
try await withRelay { h in
let req = h.anonymousRequest(.GET, "/e2e/log/subject", body: Data())
await #expect(throws: WireError.self) {
_ = try await h.routes.logSubjectEntries(req)
}
}
}
/// 🔴 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.
@Test("a stranger may read the log")
func strangersMayRead() async throws {
try await withRelay { h in
try await entry(h.db, subject: "fnidA", device: "d1", kind: "publish", user: 7)
#expect(try await fetch(h, subject: "fnidA").contains("d1"))
}
}
}
+25
View File
@@ -153,3 +153,28 @@ All optional. Each is absent rather than half-enabled when unconfigured.
| `FN_DONATE_PROVIDER`, `FN_DONATE_SECRET` | Off unless set. A relay somebody else runs has no business soliciting money for us. |
| `FN_DONATE_PUBLISHABLE_KEY` | Required alongside the two above: the payment fields are Stripe's own, mounted on the page, and they cannot mount without it. Set the secret and not this and the donate page shows no form at all. |
| `FN_DONATE_ACCOUNT` | The connected account to charge, if `FN_DONATE_SECRET` belongs to a Stripe *platform* rather than to the account meant to receive the money. Sent as `Stripe-Account`, which makes the payment a direct charge there. Blank charges whatever account the key itself belongs to — the right answer when you are self-hosting with your own key, and the wrong one with a platform key, where Stripe answers 200 and the money lands on the platform with nothing on the page looking wrong. With this set, the publishable key stays the **platform's**. |
## Watching the key log (relay#32)
`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
transparency log, and a monitor is by definition somebody else.
⚠️ **That has a cost, and it is stated here rather than left to be discovered.**
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.
`Scripts/watch-key-log.py` 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.
⚠️ It reports what the relay **serves**. 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".