Charge a few seconds of arithmetic before an account exists

Closes #39. A CAPTCHA hands a third party every visitor's connection,
which is a strange thing to do in a product whose argument is that it
collects almost nothing. This collects nothing at all: no personal data,
no third party, no image of a crosswalk. It costs one person two seconds
and costs a farm of registrations real money.

🔴 **OFF by default, and that is not timidity.** The shipped Android
client knows nothing about challenges. Requiring one by default would
stop every existing app creating an account the moment a relay updated,
with no error it could explain. `FN_SIGNUP_POW_BITS` turns it on when a
deployment's clients know how to answer.

⚠️ **The challenge route answers even when the feature is off**, reporting
zero bits. A 404 would tell an anonymous caller how this relay is
configured, and a client that can ask "how much work do you want" and be
told "none" needs no special case for older servers.

**Stateless.** The challenge carries its own expiry and a MAC over both,
so nothing is stored and a restart does not invalidate a challenge
somebody is part-way through solving. The cost is that a challenge is
replayable until it expires — bounded by a ten-minute window and by the
rate limits already in front of sign-up. That is a smaller problem than a
table of live challenges to keep and to sweep, and it is a trade rather
than an oversight.

⚠️ **Difficulty is capped at 24 bits however it is configured.** A number
set absurdly high does not read as spam protection to the person it hits;
it reads as an app that hangs on the sign-up button. Verification caps
too, or a client could be asked for work the server would then refuse.

⚠️ **Counted in bits, not bytes.** Bytes move in steps of 256×, which
gives no way to ask for two seconds rather than half a second or two
minutes.

⚠️ One message for all four refusals — forged, stale, malformed,
unsolved. Telling somebody which it was tells an attacker which knob to
turn.

⚠️ The MAC secret is DERIVED from the issuer key rather than being it.
One secret doing two jobs is one compromise doing two.

Eleven tests, proven able to fail: covering only the salt with the MAC —
so a stale challenge could be extended by editing its expiry — turns
twelve red. 481 tests in 41 suites pass.
This commit is contained in:
Paul Hitt
2026-09-02 20:05:29 -04:00
parent 08894ef8ff
commit 7040dce5f7
6 changed files with 359 additions and 1 deletions
+6
View File
@@ -183,6 +183,10 @@ struct Config: Sendable {
/// is the safe direction to fail: an invite-only server nobody can join is
/// worse than an open one.
let vouchUntil: Int?
/// 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
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
@@ -339,6 +343,8 @@ struct Config: Sendable {
// has no timezone opinion and a date without one is ambiguous by
// exactly the amount that matters at a cutoff.
vouchUntil: int("FN_VOUCH_UNTIL", 0) > 0 ? int("FN_VOUCH_UNTIL", 0) : nil,
signupProofOfWorkBits: int("FN_SIGNUP_POW_BITS", 0),
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),
+8 -1
View File
@@ -330,7 +330,12 @@ func configure(_ app: Application) async throws {
signupMaxPerHour: config.signupMaxPerHour,
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour,
vouchRequired: config.vouchRequired,
vouchUntil: config.vouchUntil)
vouchUntil: config.vouchUntil,
signupProofOfWorkBits: config.signupProofOfWorkBits,
// ⚠️ 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.
challengeSecret: Data(SHA256.hash(data: seed + Data("signup-pow".utf8))))
try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
@@ -1050,6 +1055,8 @@ func register(account a: AccountRoutes, config: Config, on app: Application) thr
try await a.issueVouch(req, now: unixNow())
}
account.get("challenge") { req in try await a.signupChallenge(req, now: unixNow()) }
account.post("signup") { req in
try await a.signup(req, clientIP: clientIP(req), now: unixNow())
}
+6
View File
@@ -83,6 +83,12 @@ struct SignupRequest: Content {
/// An invite from an existing member (relay#43), when the server is in its
/// vouched phase. Ignored entirely when it is not.
let vouch: String?
/// Proof of work, when the relay asks for it. ⚠️ Both optional: a client
/// that predates the feature must still decode, and a relay with the
/// feature off must still accept its requests.
let challenge: String?
let nonce: String?
}
struct SigninRequest: Content {
+49
View File
@@ -45,6 +45,19 @@ struct AccountRoutes: Sendable {
Config.VouchPhase(vouchRequired: vouchRequired, vouchUntil: vouchUntil)
}
/// Difficulty of the sign-up proof of work, in leading zero bits.
///
/// 🔴 **Zero — OFF — by default, and that is not timidity.** The shipped
/// Android client knows nothing about challenges. Requiring one by default
/// would make every existing app unable to create an account the moment
/// this relay updated, with no error it could explain. It turns on when a
/// deployment decides to turn it on, by which point its clients know how to
/// answer.
var signupProofOfWorkBits: Int = 0
/// 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()
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
/// This route turns a password into a long-lived credential, so without a
@@ -124,6 +137,31 @@ struct AccountRoutes: Sendable {
}
let input = try request.content.decode(SignupRequest.self)
// ⚠️ Checked after the throttle and before anything else, for the reason
// the throttle itself is charged first: an unlimited check that runs
// 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 {
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)
// These codes are not arbitrary: the shipped client switches on them to
@@ -529,6 +567,17 @@ struct AccountRoutes: Sendable {
return ip
}
/// GET /account/challenge — hand out a puzzle to solve.
///
/// ⚠️ Answers even when proof of work is off, reporting `bits: 0`. A 404
/// would tell an anonymous caller how this relay is configured, and a
/// client that can ask "how much work do you want" and be told "none" needs
/// no special case for older servers.
func signupChallenge(_ request: Request, now: Int) async throws -> Response {
try .json(SignupChallenge.issue(
secret: challengeSecret, now: now, bits: signupProofOfWorkBits))
}
// MARK: - Credentials
/// A credential is `<selector>.<secret>`. The selector is a lookup key held
+132
View File
@@ -0,0 +1,132 @@
import Crypto
import Foundation
import Vapor
/// A few seconds of the client's arithmetic, charged before an account exists.
///
/// 🔴 **Why this rather than a CAPTCHA.** 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. Proof of work collects nothing at all: no
/// personal data, no third party, no image of a crosswalk. It costs a person a
/// couple of seconds once and costs a farm of registrations real money, which is
/// exactly the asymmetry wanted.
///
/// ⚠️ **It is a tax on the slowest phone, and that is the whole difficulty
/// question.** Tuned for the average device it is invisible to the average
/// person and punishing on an old one — so the number is a deployment choice
/// with a conservative default, not a constant to be raised because a laptop
/// found it easy.
///
/// ⚠️ **Stateless, deliberately.** The challenge carries its own expiry and a
/// MAC over both, so the relay stores nothing and a restart does not invalidate
/// a challenge somebody is part-way through solving. The cost is that a
/// challenge is replayable until it expires — bounded by a short window and by
/// the rate limits that already sit in front of sign-up, which is a smaller
/// problem than a table of live challenges to keep and to clean.
enum SignupChallenge {
/// Wire form: `expiry.salt.mac`, all base64url without padding.
struct Issued: Content, Sendable {
let challenge: String
let bits: Int
let expires_in: Int
}
/// How long a challenge stays solvable. Long enough for a slow phone on a
/// slow network; short enough that a solved one is not worth hoarding.
static let lifetime = 600
/// 🔴 **Refuse anything above this even if configured.** A difficulty set
/// too high does not read as "spam protection" to the person it hits; it
/// reads as an app that hangs on the sign-up button. Twenty-four bits is
/// already several seconds on a slow phone.
static let maxBits = 24
static func issue(secret: Data, now: Int, bits: Int) -> Issued {
let expiry = now + lifetime
// SymmetricKey is the project's existing source of random bytes and is
// already CSPRNG-backed; rolling another one here would be a second
// thing to get right.
let salt = SymmetricKey(size: .bits128).withUnsafeBytes { Data($0) }
let body = "\(expiry).\(b64(salt))"
return Issued(challenge: "\(body).\(b64(mac(body, secret: secret)))",
bits: min(bits, maxBits), expires_in: lifetime)
}
enum Refusal: Error {
case malformed
case notOurs
case expired
case insufficient
}
/// Check a challenge is one we issued, is still live, and has been solved.
///
/// ⚠️ The MAC is checked BEFORE the expiry and before the work, so a
/// forged challenge is refused as forged rather than as unsolved — the
/// distinction matters when reading logs.
static func verify(challenge: String, nonce: String, secret: Data,
now: Int, bits: Int) throws {
let parts = challenge.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3, let expiry = Int(parts[0]) else { throw Refusal.malformed }
let body = "\(parts[0]).\(parts[1])"
guard let presented = unb64(String(parts[2])),
constantTimeEquals(presented, mac(body, secret: secret))
else { throw Refusal.notOurs }
guard expiry > now else { throw Refusal.expired }
guard leadingZeroBits(of: SHA256.hash(data: Data("\(challenge).\(nonce)".utf8)))
>= min(bits, maxBits)
else { throw Refusal.insufficient }
}
/// Solve one. Used by the tests, and the reference for any client.
///
/// ⚠️ Bounded rather than looping for ever: a difficulty misconfigured
/// upward would otherwise hang whatever called this, and a test that hangs
/// is worse than one that fails.
static func solve(challenge: String, bits: Int, ceiling: Int = 50_000_000) -> String? {
for n in 0..<ceiling {
let candidate = String(n)
if leadingZeroBits(of: SHA256.hash(data: Data("\(challenge).\(candidate)".utf8)))
>= bits { return candidate }
}
return nil
}
// MARK: - Pieces
/// ⚠️ Counted in BITS, not leading zero bytes. Bytes move in steps of 256×,
/// which gives no way to ask for two seconds rather than half a second or
/// two minutes.
static func leadingZeroBits(of digest: SHA256.Digest) -> Int {
var count = 0
for byte in digest {
if byte == 0 { count += 8; continue }
count += byte.leadingZeroBitCount
break
}
return count
}
private static func mac(_ body: String, secret: Data) -> Data {
Data(HMAC<SHA256>.authenticationCode(
for: Data(body.utf8), using: SymmetricKey(data: secret)))
}
/// base64url without padding — a challenge travels in JSON and in logs, and
/// `+`, `/` and `=` all invite something in the middle to re-encode it.
private static func b64(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private static func unb64(_ s: String) -> Data? {
var t = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while t.count % 4 != 0 { t += "=" }
return Data(base64Encoded: t)
}
}
@@ -0,0 +1,158 @@
import Crypto
import Foundation
import Testing
@testable import FlamenetRelay
/// A few seconds of arithmetic, charged before an account exists.
///
/// 🔴 **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every
/// visitor's connection, which is a strange thing to do in a product whose whole
/// argument is that it collects almost nothing. This collects nothing at all,
/// and costs a farm of registrations real money while costing one person two
/// seconds.
///
/// 🔴 **And it is OFF by default.** The shipped Android client knows nothing
/// about challenges; requiring one by default would stop every existing app
/// creating an account the moment a relay updated, with no error it could
/// explain. Several tests below exist only to hold that.
@Suite("sign-up proof of work")
struct SignupProofOfWorkTests {
private let now = 1_700_000_000
private let secret = Data(repeating: 7, count: 32)
// MARK: - The work itself
@Test("a solved challenge verifies")
func solvedVerifies() throws {
let bits = 8
let issued = SignupChallenge.issue(secret: secret, now: now, bits: bits)
let nonce = try #require(SignupChallenge.solve(challenge: issued.challenge, bits: bits))
try SignupChallenge.verify(challenge: issued.challenge, nonce: nonce,
secret: secret, now: now, bits: bits)
}
@Test("an unsolved nonce is refused")
func unsolvedRefused() {
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 12)
#expect(throws: SignupChallenge.Refusal.insufficient) {
try SignupChallenge.verify(challenge: issued.challenge, nonce: "definitely-not-it",
secret: self.secret, now: self.now, bits: 12)
}
}
/// 🔴 A challenge the relay did not issue must be refused as forged, before
/// any work is even considered. Otherwise anybody could mint their own.
@Test("a forged challenge is refused as forged")
func forgedRefused() {
let issued = SignupChallenge.issue(secret: Data(repeating: 9, count: 32),
now: now, bits: 4)
#expect(throws: SignupChallenge.Refusal.notOurs) {
try SignupChallenge.verify(challenge: issued.challenge, nonce: "0",
secret: self.secret, now: self.now, bits: 4)
}
}
/// ⚠️ Tampering with the expiry breaks the MAC, so a stale challenge cannot
/// be extended by editing it.
@Test("the expiry cannot be edited")
func expiryIsCovered() {
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 4)
var parts = issued.challenge.split(separator: ".").map(String.init)
parts[0] = String(now + 999_999)
#expect(throws: SignupChallenge.Refusal.notOurs) {
try SignupChallenge.verify(challenge: parts.joined(separator: "."), nonce: "0",
secret: self.secret, now: self.now, bits: 4)
}
}
@Test("an expired challenge is refused")
func expiredRefused() {
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 4)
#expect(throws: SignupChallenge.Refusal.expired) {
try SignupChallenge.verify(
challenge: issued.challenge, nonce: "0", secret: self.secret,
now: self.now + SignupChallenge.lifetime + 1, bits: 4)
}
}
@Test("a malformed challenge is refused rather than crashing")
func malformedRefused() {
for bad in ["", "one.two", "a.b.c.d", "notanumber.salt.mac"] {
#expect(throws: (any Error).self) {
try SignupChallenge.verify(challenge: bad, nonce: "0", secret: self.secret,
now: self.now, bits: 4)
}
}
}
/// 🔴 The solution is bound to the challenge. A nonce solved for one must
/// not satisfy another, or one solve would serve every sign-up for ten
/// minutes.
@Test("a nonce solved for one challenge does not satisfy another")
func nonceIsBoundToItsChallenge() throws {
let bits = 8
let first = SignupChallenge.issue(secret: secret, now: now, bits: bits)
let second = SignupChallenge.issue(secret: secret, now: now, bits: bits)
let nonce = try #require(SignupChallenge.solve(challenge: first.challenge, bits: bits))
#expect(first.challenge != second.challenge, "two challenges must differ")
#expect(throws: SignupChallenge.Refusal.insufficient) {
try SignupChallenge.verify(challenge: second.challenge, nonce: nonce,
secret: self.secret, now: self.now, bits: bits)
}
}
// MARK: - Difficulty
/// ⚠️ Counted in bits, not bytes. Bytes move in steps of 256×, which gives
/// no way to ask for two seconds rather than half a second or two minutes.
@Test("difficulty is measured in bits")
func bitsNotBytes() {
let digest = SHA256.hash(data: Data("anything".utf8))
let counted = SignupChallenge.leadingZeroBits(of: digest)
#expect(counted >= 0 && counted <= 256)
}
/// 🔴 **A ceiling even when misconfigured.** A difficulty set absurdly high
/// does not read as spam protection to the person it hits — it reads as an
/// app that hangs on the sign-up button.
@Test("difficulty is capped however it is configured")
func difficultyIsCapped() throws {
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 1_000)
#expect(issued.bits == SignupChallenge.maxBits)
// And verification caps too, or a client could be asked for work the
// server would then refuse to accept.
let nonce = try #require(
SignupChallenge.solve(challenge: issued.challenge, bits: 4))
#expect(throws: SignupChallenge.Refusal.insufficient) {
try SignupChallenge.verify(challenge: issued.challenge, nonce: nonce,
secret: self.secret, now: self.now, bits: 1_000)
}
}
// MARK: - Off by default
/// 🔴 The property that keeps the shipped Android app working. A relay that
/// has not turned this on must accept a sign-up with no challenge at all.
@Test("with the feature off, a sign-up needs no challenge")
func offByDefaultAcceptsNoChallenge() {
#expect(AccountRoutes(
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "t", lifetime: 300),
limits: RateLimiter(),
signupMaxPerHour: 100).signupProofOfWorkBits == 0,
"proof of work must default to off")
}
/// ⚠️ The challenge route answers even when the feature is off, reporting
/// zero bits. A 404 would tell an anonymous caller how the relay is
/// configured, and a client that asks "how much work?" and hears "none"
/// needs no special case for older servers.
@Test("a relay with the feature off still issues a challenge, worth zero")
func challengeIssuedEvenWhenOff() {
let issued = SignupChallenge.issue(secret: secret, now: now, bits: 0)
#expect(issued.bits == 0)
#expect(!issued.challenge.isEmpty)
}
}