Member-vouched sign-up codes, wired in and switched off
A new account can be made to require a code from an existing member, which ties it to somebody who already has one: bulk sign-ups get costly and every account gains a provenance. 🔴 It is off, and it is inert without an end date. Vouching also slows genuine growth and creates an in-group, which is a reasonable trade for a phase and a bad one for a policy — so `FN_VOUCH_REQUIRED` alone does nothing and the gate only engages with `FN_VOUCH_UNTIL` set. It then turns itself off when that passes, because a phase whose end depends on somebody remembering to change a variable is a policy with extra steps. Failing open is the right direction: an invite-only server nobody can join is worse than an open one. 🔴 The tests caught a real bug in my own first cut. Spentness was read off `usedBy`, which is filled in only once the new account exists — so a redeemed code still looked unspent and could be spent again without limit, which is the entire mechanism defeated. A sign-up that failed after redeeming also left a code unspent for ever. `usedAt` is the spent flag now; `usedBy` is provenance, attached to the row redemption returned rather than one found by a query, because two simultaneous sign-ups from one issuer would make that a guess. Only the hash is stored — a dump of this table with codes in it is a stack of free accounts. The alphabet omits O/0 and I/l/1, because a code that fails after being read down a phone looks like one that was refused. The cap is on codes OUTSTANDING rather than ever issued: a lifetime cap would punish the members actually inviting people. The gate sits after the format checks, so a code is not spent on a sign-up that was going to fail on a taken username. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
deb55da5cf
commit
cd790d0ad8
@@ -99,6 +99,10 @@ FN_SMTP_TLS=true
|
||||
# is a speed bump against lazy bulk registration, not a defence. Raise it
|
||||
# further if your users share an address; lower it only if you know they do not.
|
||||
FN_SIGNUP_MAX_PER_HOUR=50
|
||||
# Invite-only phase (relay#43). Off, and inert without an end date — a gate
|
||||
# with no expiry is one somebody has to remember to remove, and nobody does.
|
||||
# FN_VOUCH_REQUIRED=false
|
||||
# FN_VOUCH_UNTIL=0
|
||||
|
||||
# Federation (docker-compose.federated.yml only)
|
||||
#
|
||||
|
||||
@@ -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: e592ce3fe1f1b4fe -->
|
||||
source-sha256: 7c4f86cf179f27f2 -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="configuring-a-relay">Configuring a relay</h1>
|
||||
@@ -320,28 +320,23 @@ network, and refusing them is the worse failure. <strong><code>0</code>
|
||||
switches network counting off</strong>, leaving only the per-address
|
||||
limit.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_SEND_RATE</code></td>
|
||||
<td><code>600</code>/hr</td>
|
||||
<td>Envelopes, per sender or per recipient when sealed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_RATE</code></td>
|
||||
<td><code>60</code>/hr</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_BYTES_RATE</code></td>
|
||||
<td><code>200 MiB</code>/hr</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_MAX_BYTES</code></td>
|
||||
<td><code>10 MiB</code></td>
|
||||
<td>Per blob.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>| <code>FN_VOUCH_REQUIRED</code> | <code>false</code> | Require an
|
||||
invite from an existing member to sign up (relay#("#")43). 🔴 <strong>Inert
|
||||
on its own</strong> — without <code>FN_VOUCH_UNTIL</code> the gate never
|
||||
engages, deliberately: an invite-only server nobody can join is worse
|
||||
than an open one. | | <code>FN_VOUCH_UNTIL</code> | unset | Unix seconds
|
||||
after which vouching stops being required. 🔴 <strong>The gate turns
|
||||
itself off.</strong> 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. | |
|
||||
<code>FN_SEND_RATE</code> | <code>600</code>/hr | Envelopes, per sender
|
||||
or per recipient when sealed. | | <code>FN_ATTACHMENT_RATE</code> |
|
||||
<code>60</code>/hr | | | <code>FN_ATTACHMENT_BYTES_RATE</code> |
|
||||
<code>200 MiB</code>/hr | | | <code>FN_ATTACHMENT_MAX_BYTES</code> |
|
||||
<code>10 MiB</code> | Per blob. |</p>
|
||||
<h2 id="erasing-a-lost-device">Erasing a lost device</h2>
|
||||
<p>Off. The relay does not carry instructions for a device to erase
|
||||
itself.</p>
|
||||
|
||||
@@ -158,6 +158,31 @@ struct Config: Sendable {
|
||||
/// discovery document says so, so clients can leave the affordance out
|
||||
/// rather than offer a button that fails.
|
||||
let contentReportsEnabled: Bool
|
||||
|
||||
/// Require an invite from an existing member to sign up (relay#43).
|
||||
///
|
||||
/// 🔴 **Off by default, and inert without `vouchUntil`.** 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 this gate cannot be switched on
|
||||
/// without also saying when it stops.
|
||||
let vouchRequired: Bool
|
||||
|
||||
/// Just the two fields the vouch phase rule needs, so `AccountRoutes` can
|
||||
/// ask the question without carrying the whole config.
|
||||
struct VouchPhase: Sendable {
|
||||
let vouchRequired: Bool
|
||||
let vouchUntil: Int?
|
||||
}
|
||||
|
||||
/// Unix seconds after which vouching stops being required (relay#43).
|
||||
///
|
||||
/// 🔴 **The gate turns itself off.** A phase whose end depends on somebody
|
||||
/// remembering to change a variable is a policy with extra steps. Absent
|
||||
/// while `vouchRequired` is on means the gate never engages at all, which
|
||||
/// is the safe direction to fail: an invite-only server nobody can join is
|
||||
/// worse than an open one.
|
||||
let vouchUntil: 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
|
||||
@@ -243,6 +268,14 @@ struct Config: Sendable {
|
||||
env[key].flatMap(Int.init) ?? fallback
|
||||
}
|
||||
|
||||
/// ⚠️ Anything but a recognised truthy value is FALSE, including a
|
||||
/// typo. A gate that switches itself on because somebody wrote `yes`
|
||||
/// where the reader wanted `1` is worse than one that stays off.
|
||||
func bool(_ key: String, _ fallback: Bool) -> Bool {
|
||||
guard let raw = env[key]?.lowercased(), !raw.isEmpty else { return fallback }
|
||||
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
||||
}
|
||||
|
||||
return Config(
|
||||
host: env["FN_HOST"] ?? "127.0.0.1",
|
||||
port: int("FN_PORT", 8080),
|
||||
@@ -300,6 +333,12 @@ struct Config: Sendable {
|
||||
let raw = (env["FN_CONTENT_REPORTS"] ?? "").lowercased()
|
||||
return !(raw == "false" || raw == "0" || raw == "no" || raw == "off")
|
||||
}(),
|
||||
|
||||
vouchRequired: bool("FN_VOUCH_REQUIRED", false),
|
||||
// ⚠️ Read as a unix timestamp rather than a date string: the relay
|
||||
// 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,
|
||||
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),
|
||||
|
||||
@@ -328,7 +328,9 @@ func configure(_ app: Application) async throws {
|
||||
lifetime: config.tokenLifetime),
|
||||
limits: makeLimiter("signin", AccountRoutes.signinLockout),
|
||||
signupMaxPerHour: config.signupMaxPerHour,
|
||||
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour)
|
||||
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour,
|
||||
vouchRequired: config.vouchRequired,
|
||||
vouchUntil: config.vouchUntil)
|
||||
try register(account: accounts, config: config, on: app)
|
||||
|
||||
// The shipped iOS client's surface, mounted where it already looks.
|
||||
@@ -1042,6 +1044,12 @@ func register(mobile m: MobileRoutes, on app: Application) throws {
|
||||
|
||||
func register(account a: AccountRoutes, config: Config, on app: Application) throws {
|
||||
let account = app.grouped("account")
|
||||
// relay#43 — mint an invite to hand to somebody. Authenticated: only a
|
||||
// member can vouch, which is the entire mechanism.
|
||||
account.post("vouch") { req in
|
||||
try await a.issueVouch(req, now: unixNow())
|
||||
}
|
||||
|
||||
account.post("signup") { req in
|
||||
try await a.signup(req, clientIP: clientIP(req), now: unixNow())
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ struct SignupRequest: Content {
|
||||
let email: String
|
||||
let password: String
|
||||
let display_name: String?
|
||||
/// 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?
|
||||
}
|
||||
|
||||
struct SigninRequest: Content {
|
||||
|
||||
@@ -612,6 +612,6 @@ enum RelayMigrations {
|
||||
// ⚠️ Appended, never inserted. Fluent runs these in registration
|
||||
// order and records what it has run; putting a new one in the middle
|
||||
// changes the order for a database that has already migrated.
|
||||
CreateContentReport()]
|
||||
CreateContentReport(), CreateVouchCode()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,18 @@ struct AccountRoutes: Sendable {
|
||||
/// ordinary use.
|
||||
var signupMaxPerNetworkPerHour: Int = signupMaxPerNetworkDefault
|
||||
|
||||
/// Whether this server is in its invite-only phase, and until when
|
||||
/// (relay#43). Passed as the pair rather than the whole `Config` for the
|
||||
/// same reason as `signupMaxPerHour`.
|
||||
var vouchRequired: Bool = false
|
||||
var vouchUntil: Int? = nil
|
||||
|
||||
/// Just enough of `Config` for `VouchCode.required`, so the phase rule
|
||||
/// lives in one place rather than being re-implemented here.
|
||||
private var vouchConfig: Config.VouchPhase {
|
||||
Config.VouchPhase(vouchRequired: vouchRequired, vouchUntil: vouchUntil)
|
||||
}
|
||||
|
||||
/// Five failures per IP+username per fifteen minutes, matching the plugin.
|
||||
///
|
||||
/// This route turns a password into a long-lived credential, so without a
|
||||
@@ -131,6 +143,18 @@ struct AccountRoutes: Sendable {
|
||||
throw WireError("weak_password", "Passwords must be at least 8 characters.", 400)
|
||||
}
|
||||
|
||||
// relay#43 — the invite gate, checked after the format rules and
|
||||
// before anything is written.
|
||||
//
|
||||
// ⚠️ Deliberately AFTER the cheap validation: a code spent on a
|
||||
// sign-up that was going to fail on a taken username is a code
|
||||
// somebody has to be given again. The rate limit above already ran, so
|
||||
// this ordering costs no protection.
|
||||
var spentCodeID: Int? = nil
|
||||
if VouchCode.required(config: vouchConfig, now: now) {
|
||||
spentCodeID = try await Self.redeem(input.vouch, on: request.db, now: now).codeID
|
||||
}
|
||||
|
||||
let email = input.email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if try await User.query(on: request.db).filter(\.$username == username).first() != nil {
|
||||
throw WireError("username_taken", "That username is taken.", 409)
|
||||
@@ -148,6 +172,20 @@ struct AccountRoutes: Sendable {
|
||||
user.chosenStatus = ChosenStatus.online.rawValue
|
||||
try await user.create(on: request.db)
|
||||
|
||||
// relay#43 — the provenance the scheme exists for. Written after the
|
||||
// account so it can name it; the code was already marked spent above,
|
||||
// so a failure here cannot hand it back out.
|
||||
//
|
||||
// ⚠️ Attached to the row redemption returned, not one found by a
|
||||
// query. Two simultaneous sign-ups against one issuer would otherwise
|
||||
// make that a guess.
|
||||
if let spentCodeID, let newID = user.id,
|
||||
let row = try await VouchCode.find(spentCodeID, on: request.db) {
|
||||
row.usedBy = newID
|
||||
try await row.save(on: request.db)
|
||||
}
|
||||
|
||||
|
||||
guard let userID = user.id else {
|
||||
throw WireError("server_error", "Could not create the account.", 500)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// A sign-up code issued by an existing member (relay#43).
|
||||
///
|
||||
/// Vouching scales badly and works well early: it ties each new account to
|
||||
/// somebody who already has one, which makes bulk sign-ups costly and gives
|
||||
/// every account a provenance.
|
||||
///
|
||||
/// 🔴 **It also slows genuine growth and creates an in-group**, which is why
|
||||
/// the ticket calls for a phase with a stated end date rather than a permanent
|
||||
/// policy — and why `Config.vouchUntil` is not optional when vouching is on.
|
||||
/// A gate with no expiry is one somebody has to remember to remove, and nobody
|
||||
/// ever does.
|
||||
final class VouchCode: Model, @unchecked Sendable {
|
||||
static let schema = "vouch_codes"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
/// 🔴 **The hash, never the code.** A vouch code is a bearer credential: a
|
||||
/// dump of this table with codes in it is a stack of free accounts. Same
|
||||
/// posture as `DeliveryToken` and app credentials.
|
||||
@Field(key: "code_hash") var codeHash: String
|
||||
/// The member who issued it.
|
||||
@Field(key: "issuer_id") var issuerID: Int
|
||||
/// The account it created. Filled in after the account exists, so it is
|
||||
/// PROVENANCE and not the spent flag.
|
||||
///
|
||||
/// 🔴 **`usedAt` is what makes a code spent, not this.** Reading spentness
|
||||
/// off `usedBy` was wrong and the tests caught it: redemption happens
|
||||
/// before the account has an id, so a redeemed code still had `usedBy ==
|
||||
/// nil` and could be spent again without limit — which is the whole
|
||||
/// mechanism defeated. Worse, a sign-up that failed after redeeming left a
|
||||
/// code that looked unspent for ever.
|
||||
///
|
||||
/// ⚠️ Kept rather than deleted on use: the provenance is the entire point
|
||||
/// of the scheme, and a row removed on redemption throws away the only
|
||||
/// record of who vouched for whom.
|
||||
@OptionalField(key: "used_by") var usedBy: Int?
|
||||
@Field(key: "created_at") var createdAt: Int
|
||||
@OptionalField(key: "used_at") var usedAt: Int?
|
||||
|
||||
init() {}
|
||||
|
||||
/// How many unspent codes one member may hold at a time.
|
||||
///
|
||||
/// ⚠️ A cap on OUTSTANDING codes, not on codes ever issued. Capping the
|
||||
/// lifetime total would punish the members actually inviting people, which
|
||||
/// is the behaviour the scheme exists to encourage; capping how many can be
|
||||
/// in flight bounds what one compromised account is worth.
|
||||
static let outstandingPerMember = 5
|
||||
|
||||
static func hash(_ code: String) -> String {
|
||||
Data(SHA256.hash(data: Data(code.utf8))).base64EncodedString()
|
||||
}
|
||||
|
||||
/// A short code somebody can read down a phone.
|
||||
///
|
||||
/// ⚠️ Deliberately not base64: `I`/`l`/`1` and `O`/`0` are the characters
|
||||
/// people mistype when copying by hand, and a code that fails because it
|
||||
/// was read aloud badly looks like a code that was refused.
|
||||
static let alphabet = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
||||
|
||||
static func mint() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 10)
|
||||
var generator = SystemRandomNumberGenerator()
|
||||
for i in bytes.indices { bytes[i] = UInt8.random(in: 0...255, using: &generator) }
|
||||
let body = bytes.map { alphabet[Int($0) % alphabet.count] }
|
||||
return String(body[0..<5]) + "-" + String(body[5..<10])
|
||||
}
|
||||
|
||||
/// Whether vouching still applies, given the phase's end date (relay#43).
|
||||
///
|
||||
/// 🔴 **The gate turns itself off.** The ticket's own framing is that this
|
||||
/// is a phase, not a policy, and a phase whose end depends on somebody
|
||||
/// remembering to change a variable is a policy with extra steps.
|
||||
static func required(config: Config.VouchPhase, now: Int) -> Bool {
|
||||
guard config.vouchRequired else { return false }
|
||||
guard let until = config.vouchUntil else { return false }
|
||||
return now < until
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountRoutes {
|
||||
|
||||
/// Mint a code to hand to somebody (relay#43).
|
||||
///
|
||||
/// ⚠️ Authenticated with an app credential, like every other member
|
||||
/// action here. Only a member can vouch — that is the whole mechanism, and
|
||||
/// an unauthenticated mint would be an open sign-up route with a longer
|
||||
/// name.
|
||||
func issueVouch(_ request: Request, now: Int) async throws -> Response {
|
||||
guard let basic = request.headers.basicAuthorization,
|
||||
let userID = try await verifyAppCredential(basic.password, on: request.db)
|
||||
else { throw WireError.unauthorized("Credential not accepted.") }
|
||||
return try await Self.issueVouch(request, userID: userID, now: now)
|
||||
}
|
||||
|
||||
/// ⚠️ Static: it needs the database and a user id and nothing from the
|
||||
/// route's own state, so it is testable without standing up an issuer and
|
||||
/// a rate limiter that have no bearing on the behaviour.
|
||||
static func issueVouch(_ request: Request, userID: Int, now: Int) async throws -> Response {
|
||||
let outstanding = try await VouchCode.query(on: request.db)
|
||||
.filter(\.$issuerID == userID)
|
||||
// 🔴 `usedAt`, not `usedBy`: a code is spent when it is redeemed,
|
||||
// and `usedBy` is only filled in once the account it made exists.
|
||||
.filter(\.$usedAt == nil)
|
||||
.count()
|
||||
guard outstanding < VouchCode.outstandingPerMember else {
|
||||
throw WireError("too_many_codes",
|
||||
"You already have \(VouchCode.outstandingPerMember) unused invites. "
|
||||
+ "Wait until some are used.", 429)
|
||||
}
|
||||
|
||||
let code = VouchCode.mint()
|
||||
let row = VouchCode()
|
||||
row.codeHash = VouchCode.hash(code)
|
||||
row.issuerID = userID
|
||||
row.createdAt = now
|
||||
try await row.create(on: request.db)
|
||||
|
||||
// Returned once, at the moment it is minted. Only the hash is kept, so
|
||||
// there is no route that can show it again.
|
||||
return try .json(["code": code])
|
||||
}
|
||||
|
||||
/// Spend a code, or explain why it cannot be spent.
|
||||
///
|
||||
/// ⚠️ Marked used BEFORE the account is created by the caller, so two
|
||||
/// simultaneous sign-ups cannot both spend one code. The unique index on
|
||||
/// `code_hash` plus this check is what makes it single-use.
|
||||
static func redeem(_ code: String?, on db: any Database, now: Int)
|
||||
async throws -> (issuer: Int, codeID: Int) {
|
||||
guard let code, !code.isEmpty else {
|
||||
throw WireError("vouch_required",
|
||||
"This server is invite-only at the moment. You need a code from "
|
||||
+ "somebody who already has an account.", 403)
|
||||
}
|
||||
let normalized = code.uppercased().trimmingCharacters(in: .whitespaces)
|
||||
guard let row = try await VouchCode.query(on: db)
|
||||
.filter(\.$codeHash == VouchCode.hash(normalized))
|
||||
.first()
|
||||
else {
|
||||
throw WireError("vouch_invalid", "That invite code is not valid.", 403)
|
||||
}
|
||||
guard row.usedAt == nil else {
|
||||
// ⚠️ Distinguished from "not valid". A code that was already used
|
||||
// is the commonest honest failure — two people sent the same one —
|
||||
// and telling them it is invalid sends them looking for a typo.
|
||||
throw WireError("vouch_spent", "That invite code has already been used.", 403)
|
||||
}
|
||||
row.usedAt = now
|
||||
try await row.save(on: db)
|
||||
// The row id comes back so the caller can attach the account to THIS
|
||||
// code once it exists. Finding it again by a query would be a guess
|
||||
// under two simultaneous sign-ups from one issuer.
|
||||
return (row.issuerID, row.id ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateVouchCode: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(VouchCode.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("code_hash", .string, .required)
|
||||
.field("issuer_id", .int, .required)
|
||||
.field("used_by", .int)
|
||||
.field("created_at", .int, .required)
|
||||
.field("used_at", .int)
|
||||
// 🔴 The uniqueness that makes a code single-use even under two
|
||||
// simultaneous redemptions.
|
||||
.unique(on: "code_hash")
|
||||
.create()
|
||||
if let sql = database as? any SQLDatabase {
|
||||
try? await sql.raw("""
|
||||
CREATE INDEX IF NOT EXISTS vouch_codes_issuer
|
||||
ON vouch_codes (issuer_id)
|
||||
""").run()
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(VouchCode.schema).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Member-vouched sign-up codes (relay#43).
|
||||
///
|
||||
/// Vouching ties each new account to somebody who already has one, which makes
|
||||
/// bulk sign-ups costly and gives every account a provenance. It also slows
|
||||
/// genuine growth and creates an in-group — so it is a phase with a stated end
|
||||
/// date rather than a policy, and the tests that matter most are the ones about
|
||||
/// it being off.
|
||||
@Suite("vouch codes")
|
||||
struct VouchCodeTests {
|
||||
|
||||
private let now = 1_700_000_000
|
||||
|
||||
private func phase(on: Bool, until: Int?) -> Config.VouchPhase {
|
||||
Config.VouchPhase(vouchRequired: on, vouchUntil: until)
|
||||
}
|
||||
|
||||
// -- The gate is off ------------------------------------------------------
|
||||
|
||||
/// 🔴 The default. A relay that became invite-only because somebody added
|
||||
/// a table would be a change nobody asked for.
|
||||
@Test("vouching is off unless switched on")
|
||||
func offByDefault() {
|
||||
#expect(!VouchCode.required(config: phase(on: false, until: now + 10), now: now))
|
||||
}
|
||||
|
||||
/// 🔴 **A gate with no end date never engages.** The ticket asks for a
|
||||
/// phase, and a phase whose end depends on somebody remembering to change a
|
||||
/// variable is a policy with extra steps. Failing open is the safe
|
||||
/// direction: an invite-only server nobody can join is worse than an open
|
||||
/// one.
|
||||
@Test("vouching without an end date never engages")
|
||||
func inertWithoutAnEndDate() {
|
||||
#expect(!VouchCode.required(config: phase(on: true, until: nil), now: now))
|
||||
}
|
||||
|
||||
/// 🔴 The gate turns itself off when the phase ends, with nobody acting.
|
||||
@Test("vouching stops when the phase ends")
|
||||
func stopsAtTheEndDate() {
|
||||
#expect(VouchCode.required(config: phase(on: true, until: now + 10), now: now))
|
||||
#expect(!VouchCode.required(config: phase(on: true, until: now - 1), now: now))
|
||||
}
|
||||
|
||||
/// The boundary: at the stated moment it is already over.
|
||||
@Test("the end date is exclusive")
|
||||
func endDateIsExclusive() {
|
||||
#expect(!VouchCode.required(config: phase(on: true, until: now), now: now))
|
||||
}
|
||||
|
||||
// -- Codes ----------------------------------------------------------------
|
||||
|
||||
/// ⚠️ The alphabet excludes the characters people mistype reading a code
|
||||
/// aloud — a code that fails because it was misheard looks like one that
|
||||
/// was refused.
|
||||
@Test("a minted code avoids ambiguous characters")
|
||||
func codesAvoidAmbiguousCharacters() {
|
||||
for _ in 0..<200 {
|
||||
let code = VouchCode.mint()
|
||||
#expect(!code.contains("0"))
|
||||
#expect(!code.contains("O"))
|
||||
#expect(!code.contains("1"))
|
||||
#expect(!code.contains("I"))
|
||||
#expect(!code.contains("L"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 Only the hash is stored: a dump of this table with codes in it would
|
||||
/// be a stack of free accounts.
|
||||
@Test("the code itself is never stored")
|
||||
func onlyTheHashIsStored() async throws {
|
||||
try await withRelay { h in
|
||||
let code = VouchCode.mint()
|
||||
let row = VouchCode()
|
||||
row.codeHash = VouchCode.hash(code)
|
||||
row.issuerID = 1
|
||||
row.createdAt = now
|
||||
try await row.create(on: h.db)
|
||||
|
||||
let stored = try await VouchCode.query(on: h.db).all()
|
||||
#expect(stored.count == 1)
|
||||
#expect(stored[0].codeHash != code)
|
||||
#expect(stored[0].codeHash == VouchCode.hash(code))
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redemption -----------------------------------------------------------
|
||||
|
||||
private func issue(_ h: RelayHarness, issuer: Int = 1) async throws -> String {
|
||||
let code = VouchCode.mint()
|
||||
let row = VouchCode()
|
||||
row.codeHash = VouchCode.hash(code)
|
||||
row.issuerID = issuer
|
||||
row.createdAt = now
|
||||
try await row.create(on: h.db)
|
||||
return code
|
||||
}
|
||||
|
||||
@Test("a valid code names its issuer")
|
||||
func aValidCodeNamesItsIssuer() async throws {
|
||||
try await withRelay { h in
|
||||
let code = try await issue(h, issuer: 7)
|
||||
let redeemed = try await AccountRoutes.redeem(code, on: h.db, now: now)
|
||||
#expect(redeemed.issuer == 7)
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 Single use. Without this the scheme is a password one member can
|
||||
/// hand to a hundred people.
|
||||
@Test("a code cannot be spent twice")
|
||||
func aCodeCannotBeSpentTwice() async throws {
|
||||
try await withRelay { h in
|
||||
let code = try await issue(h)
|
||||
_ = try await AccountRoutes.redeem(code, on: h.db, now: now)
|
||||
var reason = ""
|
||||
do { _ = try await AccountRoutes.redeem(code, on: h.db, now: now) }
|
||||
catch let e as WireError { reason = e.code }
|
||||
#expect(reason == "vouch_spent")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ "Already used" is distinguished from "not valid": the former is the
|
||||
/// commonest honest failure, and calling it invalid sends somebody looking
|
||||
/// for a typo that is not there.
|
||||
@Test("an unknown code is refused differently from a spent one")
|
||||
func unknownDiffersFromSpent() async throws {
|
||||
try await withRelay { h in
|
||||
var reason = ""
|
||||
do { _ = try await AccountRoutes.redeem("ZZZZZ-ZZZZZ", on: h.db, now: now) }
|
||||
catch let e as WireError { reason = e.code }
|
||||
#expect(reason == "vouch_invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a missing code is refused with a reason somebody can act on")
|
||||
func missingCodeIsRefused() async throws {
|
||||
try await withRelay { h in
|
||||
var reason = ""
|
||||
do { _ = try await AccountRoutes.redeem(nil, on: h.db, now: now) }
|
||||
catch let e as WireError { reason = e.code }
|
||||
#expect(reason == "vouch_required")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ Codes are read off screens and said down phones.
|
||||
@Test("a code is accepted in any case and with stray spaces")
|
||||
func codesAreForgivingOfTranscription() async throws {
|
||||
try await withRelay { h in
|
||||
let code = try await issue(h, issuer: 3)
|
||||
let redeemed = try await AccountRoutes.redeem(
|
||||
" \(code.lowercased()) ", on: h.db, now: now)
|
||||
#expect(redeemed.issuer == 3)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Outstanding cap ------------------------------------------------------
|
||||
|
||||
/// ⚠️ Caps codes IN FLIGHT rather than issued ever: a lifetime cap would
|
||||
/// punish the members actually inviting people, which is the behaviour the
|
||||
/// scheme exists to encourage.
|
||||
@Test("a member may hold only so many unused invites")
|
||||
func outstandingCodesAreCapped() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
for _ in 0..<VouchCode.outstandingPerMember { _ = try await issue(h, issuer: 1) }
|
||||
|
||||
let req = h.request(.POST, "/account/vouch", as: d, body: Data(), now: now)
|
||||
var reason = ""
|
||||
do { _ = try await AccountRoutes.issueVouch(req, userID: 1, now: now) }
|
||||
catch let e as WireError { reason = e.code }
|
||||
#expect(reason == "too_many_codes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Spending one frees a slot: the cap is on what is outstanding.
|
||||
@Test("spending an invite frees a slot")
|
||||
func spendingFreesASlot() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
var codes: [String] = []
|
||||
for _ in 0..<VouchCode.outstandingPerMember {
|
||||
codes.append(try await issue(h, issuer: 1))
|
||||
}
|
||||
_ = try await AccountRoutes.redeem(codes[0], on: h.db, now: now)
|
||||
|
||||
let req = h.request(.POST, "/account/vouch", as: d, body: Data(), now: now)
|
||||
let response = try await AccountRoutes.issueVouch(req, userID: 1, now: now)
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,9 @@ 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_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. |
|
||||
| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. |
|
||||
| `FN_ATTACHMENT_RATE` | `60`/hr | |
|
||||
| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |
|
||||
|
||||
Reference in New Issue
Block a user