Switching the sign-up work on no longer walls out an old build (relay#52)
`FN_SIGNUP_POW_BITS` has existed and been off since it was written, and it
could not simply be turned on: the gate refused any sign-up that arrived
without a challenge, so enabling it would have broken account creation for
every build already in somebody's hand — and broken it invisibly, as an app
that just does not work.
The issue assumed the shipped Android client could answer. It cannot. The APK
in the field is 0.6, released 2026-08-31; `SignupProofOfWork.kt` landed
2026-09-01, one day later. iOS has it on main and has not shipped at all. So
the shipped floor on both platforms is a client that cannot solve a challenge,
and the wall was never a setting anyone could safely flip.
A client that does not answer now drops to a strict per-address allowance —
`FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW`, default 5 — instead of being refused.
That is the accidental backstop that existed before the per-address limit was
raised from 5 to 50 in relay#9, applied only to callers that cannot do the
work. Doing the work is what buys the generous allowance, so the opt-out costs
something rather than being free.
Presenting a *wrong* answer is still refused outright. Sending garbage must not
be a cheaper route to the lenient path than sending nothing, or the work is
optional for anyone who reads this file.
`0` restores the wall, deliberately: that is the right setting once both
shipped clients solve challenges, and it should not have been designed away.
Six tests, and both halves were falsified against the unfixed code:
disabling the fallback branch turns "a client that cannot answer still gets an
account" and "skipping the work drops the caller to the strict allowance" red;
charging the generous limit in the fallback turns the second one red on its
own ("an error was expected but none was thrown").
⚠️ Honest limit, written in the code as well as here: per-address is trivially
rotated, so the strict fallback bounds a lazy attacker and not a determined
one. The gain is not that the fallback is strong — it is that the strong thing
can now be enabled without stranding real people on an old build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e1792f638
commit
01625a6cbb
@@ -187,6 +187,10 @@ struct Config: Sendable {
|
||||
/// Leading zero bits of work asked of a new account. 0 = off, and off is
|
||||
/// the default: the shipped clients do not know how to answer yet.
|
||||
let signupProofOfWorkBits: Int
|
||||
/// Per-address hourly allowance for a client that does not answer the
|
||||
/// challenge, so switching the work on degrades an old build rather than
|
||||
/// walling it out. `0` restores the wall. See `AccountRoutes`.
|
||||
let signupMaxPerHourWithoutProofOfWork: Int
|
||||
/// Whether an emailed code must be spent before an account can mint a relay
|
||||
/// token.
|
||||
///
|
||||
@@ -363,6 +367,9 @@ struct Config: Sendable {
|
||||
vouchUntil: int("FN_VOUCH_UNTIL", 0) > 0 ? int("FN_VOUCH_UNTIL", 0) : nil,
|
||||
|
||||
signupProofOfWorkBits: int("FN_SIGNUP_POW_BITS", 0),
|
||||
signupMaxPerHourWithoutProofOfWork: int(
|
||||
"FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW",
|
||||
AccountRoutes.signupWithoutProofOfWorkDefault),
|
||||
emailVerificationRequired: {
|
||||
// Opt-in only: anything that is not an explicit yes leaves it
|
||||
// off. Unset, empty and misspelt all mean the same safe thing.
|
||||
|
||||
@@ -345,6 +345,7 @@ func configure(_ app: Application) async throws {
|
||||
vouchRequired: config.vouchRequired,
|
||||
vouchUntil: config.vouchUntil,
|
||||
signupProofOfWorkBits: config.signupProofOfWorkBits,
|
||||
signupMaxPerHourWithoutProofOfWork: config.signupMaxPerHourWithoutProofOfWork,
|
||||
// ⚠️ Derived, not stored: stable across restarts so a challenge in
|
||||
// flight survives a deploy, and never the issuer key itself — one
|
||||
// secret doing two jobs is one compromise doing two.
|
||||
|
||||
@@ -54,6 +54,36 @@ struct AccountRoutes: Sendable {
|
||||
/// deployment decides to turn it on, by which point its clients know how to
|
||||
/// answer.
|
||||
var signupProofOfWorkBits: Int = 0
|
||||
|
||||
/// What a client that cannot do the work is allowed instead, per address
|
||||
/// per hour. Only consulted when `signupProofOfWorkBits > 0`.
|
||||
///
|
||||
/// 🔴 **This is what makes the proof of work switchable on at all.** The
|
||||
/// gate above used to refuse outright, so enabling it would have broken
|
||||
/// sign-up for every build already in somebody's hand — and it would have
|
||||
/// presented as the app not working, with nothing the person could do. The
|
||||
/// APK in the field is Android 0.6, released 2026-08-31; the client-side
|
||||
/// solver landed 2026-09-01, one day later. So the shipped floor genuinely
|
||||
/// cannot answer a challenge today, and "just turn it on" was never
|
||||
/// available.
|
||||
///
|
||||
/// 🔴 **The opt-out costs something, which is the whole design.** A client
|
||||
/// that skips the work is not waved through — it drops from the generous
|
||||
/// per-address allowance to this strict one, which is the accidental
|
||||
/// backstop that used to exist before the limit was raised from 5 to 50
|
||||
/// (relay#9). Wanting the generous allowance means spending the CPU.
|
||||
///
|
||||
/// ⚠️ **Honest about what that buys.** Per-address is trivially rotated, so
|
||||
/// this bounds a lazy attacker and not a determined one. The gain is not
|
||||
/// that the fallback is strong; it is that the strong thing can now be
|
||||
/// enabled without stranding real people on an old build.
|
||||
///
|
||||
/// ⚠️ **`0` restores the wall** — no fallback, a challenge or nothing. That
|
||||
/// is the right setting once the shipped floor on both platforms solves
|
||||
/// them, and it is deliberately reachable rather than hard-coded away.
|
||||
/// `FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW`.
|
||||
var signupMaxPerHourWithoutProofOfWork: Int = AccountRoutes.signupWithoutProofOfWorkDefault
|
||||
|
||||
/// Keys the challenge MAC. Derived from the issuer key at boot so a restart
|
||||
/// does not invalidate a challenge somebody is part-way through solving.
|
||||
var challengeSecret: Data = Data()
|
||||
@@ -139,6 +169,13 @@ struct AccountRoutes: Sendable {
|
||||
/// Eight times the per-address allowance: enough that a shared network of
|
||||
/// real people never notices, small enough that a subnet cannot be farmed.
|
||||
static let signupMaxPerNetworkDefault = 400
|
||||
/// The pre-2026-08-30 per-address limit, kept for exactly one purpose: what
|
||||
/// a client too old to do the proof of work falls back to. Five is too
|
||||
/// strict as a general rule — it refuses the sixth person behind a shared
|
||||
/// connection — and that is tolerable *here* because it applies only while
|
||||
/// a challenge is being demanded and only to clients that cannot answer
|
||||
/// one, which is a shrinking set rather than everybody.
|
||||
static let signupWithoutProofOfWorkDefault = 5
|
||||
|
||||
// MARK: - POST /account/signup
|
||||
|
||||
@@ -171,23 +208,31 @@ struct AccountRoutes: Sendable {
|
||||
// before the cost is a free oracle. This one is also the cheapest
|
||||
// possible refusal — a hash — so it belongs ahead of the database.
|
||||
if signupProofOfWorkBits > 0 {
|
||||
guard let challenge = input.challenge, let nonce = input.nonce else {
|
||||
if let challenge = input.challenge, let nonce = input.nonce {
|
||||
do {
|
||||
try SignupChallenge.verify(challenge: challenge, nonce: nonce,
|
||||
secret: challengeSecret, now: now,
|
||||
bits: signupProofOfWorkBits)
|
||||
} catch {
|
||||
// ⚠️ One message for all four refusals. Telling somebody
|
||||
// whether their challenge was forged, stale or merely unsolved
|
||||
// is telling an attacker which knob to turn.
|
||||
throw WireError("challenge_failed",
|
||||
"That took too long, or the check did not come out right. "
|
||||
+ "Please try again.", 400)
|
||||
}
|
||||
} else if signupMaxPerHourWithoutProofOfWork > 0 {
|
||||
guard await limits.consumeAll(
|
||||
[(key: "signup:nopow:\(clientIP)", amount: 1,
|
||||
limit: signupMaxPerHourWithoutProofOfWork)], now: now) else {
|
||||
throw WireError.rateLimited(
|
||||
"Too many accounts created from here recently. Try again later.")
|
||||
}
|
||||
} else {
|
||||
throw WireError("challenge_required",
|
||||
"This server asks new accounts to do a few seconds of work first. "
|
||||
+ "Update the app, or try again from a newer one.", 400)
|
||||
}
|
||||
do {
|
||||
try SignupChallenge.verify(challenge: challenge, nonce: nonce,
|
||||
secret: challengeSecret, now: now,
|
||||
bits: signupProofOfWorkBits)
|
||||
} catch {
|
||||
// ⚠️ One message for all four refusals. Telling somebody
|
||||
// whether their challenge was forged, stale or merely unsolved
|
||||
// is telling an attacker which knob to turn.
|
||||
throw WireError("challenge_failed",
|
||||
"That took too long, or the check did not come out right. "
|
||||
+ "Please try again.", 400)
|
||||
}
|
||||
}
|
||||
|
||||
let username = User.normalize(input.username)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
@@ -187,3 +189,166 @@ struct SignupChallengeReachabilityTests {
|
||||
"duplicate route in the namespace index: \(routes)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// What happens to a client that *cannot* answer, once the work is switched on.
|
||||
///
|
||||
/// 🔴 **This is the whole of relay#52.** The gate used to refuse outright, which
|
||||
/// meant enabling the proof of work would have broken sign-up for every build
|
||||
/// already in somebody's hand — and broken it invisibly, as an app that simply
|
||||
/// does not work. The APK in the field is Android 0.6 (2026-08-31); the
|
||||
/// client-side solver landed 2026-09-01, one day later. So the shipped floor
|
||||
/// really cannot answer a challenge, and the wall was not a setting anyone
|
||||
/// could turn on.
|
||||
///
|
||||
/// The rule now: an old build degrades to the strict per-address limit that
|
||||
/// used to exist accidentally, rather than hitting a wall. Doing the work is
|
||||
/// what buys the generous allowance.
|
||||
@Suite("switching the work on does not wall out an old build")
|
||||
struct SignupProofOfWorkFallbackTests {
|
||||
|
||||
private let now = 1_700_000_000
|
||||
private let ip = "203.0.113.7"
|
||||
private let secret = Data(repeating: 7, count: 32)
|
||||
|
||||
private func routes(_ h: RelayHarness, bits: Int, withoutPoW: Int) -> AccountRoutes {
|
||||
AccountRoutes(
|
||||
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
|
||||
audience: "test.relay", lifetime: 300),
|
||||
limits: h.routes.limits,
|
||||
signupMaxPerHour: 100,
|
||||
signupProofOfWorkBits: bits,
|
||||
signupMaxPerHourWithoutProofOfWork: withoutPoW,
|
||||
challengeSecret: secret)
|
||||
}
|
||||
|
||||
private func post(_ h: RelayHarness, _ body: String) -> Request {
|
||||
var headers = HTTPHeaders()
|
||||
headers.contentType = .json
|
||||
var buf = ByteBufferAllocator().buffer(capacity: body.utf8.count)
|
||||
buf.writeString(body)
|
||||
return Request(application: h.app, method: .POST, url: URI(path: "/account/signup"),
|
||||
headers: headers, collectedBody: buf,
|
||||
on: h.app.eventLoopGroup.next())
|
||||
}
|
||||
|
||||
/// `n` distinguishes the accounts; nothing else varies.
|
||||
private func body(_ n: Int, challenge: String? = nil, nonce: String? = nil) -> String {
|
||||
var parts = [#""username":"ada\#(n)""#, #""email":"ada\#(n)@example.com""#,
|
||||
#""password":"hunter2hunter2""#]
|
||||
if let challenge { parts.append(#""challenge":"\#(challenge)""#) }
|
||||
if let nonce { parts.append(#""nonce":"\#(nonce)""#) }
|
||||
return "{\(parts.joined(separator: ","))}"
|
||||
}
|
||||
|
||||
private func code(_ error: any Error) -> String {
|
||||
(error as? WireError)?.code ?? "not-a-wire-error"
|
||||
}
|
||||
|
||||
/// 🔴 The property the whole change exists for. Before it, this threw
|
||||
/// `challenge_required` and the person saw an app that did not work.
|
||||
@Test("a client that cannot answer still gets an account")
|
||||
func oldBuildStillSignsUp() async throws {
|
||||
try await withRelay { h in
|
||||
let r = routes(h, bits: 8, withoutPoW: 5)
|
||||
let res = try await r.signup(post(h, body(1)), clientIP: ip, now: now)
|
||||
#expect(res.status == .ok)
|
||||
let created = try await User.query(on: h.db).filter(\.$username == "ada1").first()
|
||||
#expect(created != nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ **The opt-out costs something, or it is not a backstop.** Skipping the
|
||||
/// work drops the caller to the strict allowance — the one that existed
|
||||
/// accidentally before the per-address limit was raised from 5 to 50.
|
||||
@Test("skipping the work drops the caller to the strict allowance")
|
||||
func skippingTheWorkIsRationed() async throws {
|
||||
try await withRelay { h in
|
||||
let r = routes(h, bits: 8, withoutPoW: 2)
|
||||
for n in 1 ... 2 {
|
||||
let res = try await r.signup(post(h, body(n)), clientIP: ip, now: now)
|
||||
#expect(res.status == .ok)
|
||||
}
|
||||
let err = await #expect(throws: WireError.self) {
|
||||
_ = try await r.signup(self.post(h, self.body(3)), clientIP: self.ip, now: self.now)
|
||||
}
|
||||
#expect(code(err!) == "rate_limited")
|
||||
// ⚠️ And it is the *strict* bucket that stopped it, not the
|
||||
// generous one: only three attempts were made against a limit of
|
||||
// 100. Without this the test would pass for the wrong reason.
|
||||
let created = try await User.query(on: h.db).count()
|
||||
#expect(created == 2, "the strict bucket did not stop it: \(created) accounts exist")
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 The other half: doing the work buys the generous allowance. A client
|
||||
/// that answers is never charged the strict bucket, so it walks straight
|
||||
/// past the number that stopped the one above.
|
||||
@Test("doing the work buys past the strict allowance")
|
||||
func workBuysTheGenerousAllowance() async throws {
|
||||
try await withRelay { h in
|
||||
let bits = 8
|
||||
let r = routes(h, bits: bits, withoutPoW: 2)
|
||||
for n in 1 ... 4 {
|
||||
let issued = SignupChallenge.issue(secret: secret, now: now, bits: bits)
|
||||
let nonce = try #require(
|
||||
SignupChallenge.solve(challenge: issued.challenge, bits: bits))
|
||||
let res = try await r.signup(
|
||||
post(h, body(n, challenge: issued.challenge, nonce: nonce)),
|
||||
clientIP: ip, now: now)
|
||||
#expect(res.status == .ok, "attempt \(n) was refused despite doing the work")
|
||||
}
|
||||
let created = try await User.query(on: h.db).count()
|
||||
#expect(created == 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 **Presenting an answer means being held to it.** Sending a wrong
|
||||
/// challenge must not be a cheaper route to the lenient path than sending
|
||||
/// none — that would make the work optional for anyone who read this file.
|
||||
@Test("a wrong answer is refused, not quietly demoted to the lenient path")
|
||||
func aWrongAnswerIsNotAShortcut() async throws {
|
||||
try await withRelay { h in
|
||||
let r = routes(h, bits: 8, withoutPoW: 50)
|
||||
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 8)
|
||||
let err = await #expect(throws: WireError.self) {
|
||||
_ = try await r.signup(
|
||||
self.post(h, self.body(1, challenge: issued.challenge, nonce: "nope")),
|
||||
clientIP: self.ip, now: self.now)
|
||||
}
|
||||
#expect(code(err!) == "challenge_failed")
|
||||
let created = try await User.query(on: h.db).count()
|
||||
#expect(created == 0, "a wrong answer created an account")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ The wall is still reachable, and deliberately so: once both shipped
|
||||
/// clients solve challenges, `0` is the right setting and it must not have
|
||||
/// been designed away.
|
||||
@Test("zero restores the wall")
|
||||
func zeroRestoresTheWall() async throws {
|
||||
try await withRelay { h in
|
||||
let r = routes(h, bits: 8, withoutPoW: 0)
|
||||
let err = await #expect(throws: WireError.self) {
|
||||
_ = try await r.signup(self.post(h, self.body(1)),
|
||||
clientIP: self.ip, now: self.now)
|
||||
}
|
||||
#expect(code(err!) == "challenge_required")
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 None of this applies when the work is off, which is the default. A
|
||||
/// relay that never asked for a challenge must not ration anybody.
|
||||
@Test("with the work off the strict allowance is never consulted")
|
||||
func offMeansOff() async throws {
|
||||
try await withRelay { h in
|
||||
let r = routes(h, bits: 0, withoutPoW: 1)
|
||||
for n in 1 ... 3 {
|
||||
let res = try await r.signup(post(h, body(n)), clientIP: ip, now: now)
|
||||
#expect(res.status == .ok)
|
||||
}
|
||||
let created = try await User.query(on: h.db).count()
|
||||
#expect(created == 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ Defaults are sane; raise them only with a reason.
|
||||
| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone — that would let one abuser make a popular account unstartable for everyone. |
|
||||
| `FN_SIGNUP_MAX_PER_HOUR` | `50`/hr | New accounts per IP. ⚠️ An IP is not a person — carrier CGNAT, offices and campuses all share one. It was `5`, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
|
||||
| `FN_SIGNUP_MAX_PER_NETWORK` | `400`/hr | New accounts per network — a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. **`0` switches network counting off**, leaving only the per-address limit. |
|
||||
| `FN_SIGNUP_POW_BITS` | `0` (off) | Leading zero bits of SHA-256 work a new account must do before it is created (relay#52). ⚠️ **Not a CAPTCHA on purpose** — a CAPTCHA hands a third party every visitor's connection, which is a strange thing for a product whose argument is that it collects almost nothing. This collects nothing at all. Capped at `24` however it is set: higher does not read as spam protection to the person it hits, it reads as an app that hangs on the sign-up button. |
|
||||
| `FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW` | `5`/hr | What a client too old to answer the challenge gets instead, per address. 🔴 **This is what makes `FN_SIGNUP_POW_BITS` switchable on at all.** Refusing an unanswered sign-up outright would break every build already in somebody's hand, and break it invisibly — the APK in the field is Android 0.6 (2026-08-31) and the client-side solver landed 2026-09-01, one day later. An old build now drops to this strict allowance rather than hitting a wall, so the opt-out costs something without stranding anyone. ⚠️ Per-address is trivially rotated, so this bounds a lazy attacker, not a determined one; the gain is that the strong thing can be enabled at all. **`0` restores the wall** — the right setting once both shipped clients solve challenges. |
|
||||
|
||||
| `FN_VOUCH_REQUIRED` | `false` | Require an invite from an existing member to sign up (relay#43). 🔴 **Inert on its own** — without `FN_VOUCH_UNTIL` the gate never engages, deliberately: an invite-only server nobody can join is worse than an open one. |
|
||||
| `FN_VOUCH_UNTIL` | unset | Unix seconds after which vouching stops being required. 🔴 **The gate turns itself off.** Vouching makes bulk sign-ups costly and gives every account a provenance; it also slows genuine growth and creates an in-group. That is a reasonable trade for a phase and a bad one for a policy, so it cannot be switched on without saying when it stops. |
|
||||
|
||||
Reference in New Issue
Block a user