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:
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user