One token per introduction, and a small proof of work by handle

Decisions from the coordinating session, 2026-10-07:

- POST /e2e/intro mints ONE token (`intro`, `exp`, `expires_in`); the
  three-token `intro_tokens` array is gone, so unused tokens no longer eat
  the per-account cap. The cap is now 30 unspent introductions.
- An introduction by handle needs a solved challenge (decision DD2), from
  the new GET /e2e/intro/challenge, in SignupChallenge's format with its own
  MAC secret (derived "intro-pow"). FN_INTRO_POW_BITS, default 16, capped
  at 24, 0 switches it off. Each solution is accepted once (a limit-one key
  per challenge in the hourly limiter). Checked before anything is looked up
  or charged, so the refusal is the same for every name. A link needs none.
- The unlisted/missing floor is unchanged and re-measured with the work on
  (500 samples an arm): medians within -81 to +124 us of each other on
  12 ms Basic-auth doors and within 8 us on the two anonymous doors; every
  sample at or above 5 ms.

Tests: the harness runs the work at 8 bits; IntroProofOfWorkTests checks
the default, the refusal shape, wrong / spent / foreign / expired answers,
links, and 0. The SQLite upgrade test now does every DDL round trip, and
every statement after it, on one connection (a statement on another pooled
connection read a stale schema: "no such column").

Falsified: two tokens per intro, work not required, a solution spendable
twice, links asked for work, the cap constant moved, and the floor removed
each turn their tests red. Full suite: 853 swift-testing tests in 95 suites
and 61 XCTest pass, with the Postgres suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-07 14:37:39 -04:00
co-authored by Claude Opus 5.5
parent b4bf14587b
commit 78b0a576d5
14 changed files with 329 additions and 102 deletions
+29 -16
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: d71d19e64e2fe62b -->
source-sha256: a1111a39ae2bb75a -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="relay-api">Relay API</h1>
@@ -392,21 +392,34 @@ address.</p>
<tbody>
<tr>
<td><code>POST /e2e/intro</code></td>
<td><code>{"handle": h}</code> (listed accounts only) or
<code>{"link": secret}</code> (base64 or base64url, 32 bytes). Returns
<code>user_id</code>, <code>fnid</code>, <code>intro</code> and
<code>exp</code> (§14.5's shape: one token and its expiry in unix
seconds), and <code>intro_tokens</code>, three tokens of which
<code>intro</code> is the first, each good for one sealed send within 10
minutes (<code>expires_in</code>), the target's bundles
(<code>devices</code>, one-time prekeys popped) and
<code>handle_cert</code>. An unlisted or unknown handle, an unknown or
revoked link and an account with no devices all get the same
<code>404 not_found</code> "No such account.", no sooner than 5 ms.
Metered per address (<code>FN_INTRO_RATE</code>), per network
(<code>FN_INTRO_RATE_NETWORK</code>), and once an account is found, per
account (<code>FN_INTRO_RATE_TARGET</code>) and per network and account
(a third of that); at most 90 unspent intro tokens per account</td>
<td><code>{"handle": h, "challenge": c, "nonce": n}</code> (listed
accounts only) or <code>{"link": secret}</code> (base64 or base64url, 32
bytes). A handle needs a solved challenge from
<code>GET /e2e/intro/challenge</code> (<code>FN_INTRO_POW_BITS</code>,
each solution good once; <code>400 challenge_required</code> or
<code>challenge_failed</code> otherwise, the same for every name); a
link needs none. Returns <code>user_id</code>, <code>fnid</code>,
<code>intro</code>, one token good for one sealed send (as
<code>intro</code> or <code>delivery_token</code>) until
<code>exp</code> in unix seconds, 10 minutes ahead
(<code>expires_in</code>), the target's bundles (<code>devices</code>,
one-time prekeys popped) and <code>handle_cert</code>. An unlisted or
unknown handle, an unknown or revoked link and an account with no
devices all get the same <code>404 not_found</code> "No such account.",
no sooner than 5 ms. Metered per address (<code>FN_INTRO_RATE</code>),
per network (<code>FN_INTRO_RATE_NETWORK</code>), and once an account is
found, per account (<code>FN_INTRO_RATE_TARGET</code>) and per network
and account (a third of that); at most 30 unspent introductions per
account</td>
</tr>
<tr>
<td><code>GET /e2e/intro/challenge</code></td>
<td>A proof-of-work challenge for an introduction by handle, in the
sign-up challenge's format:
<code>{"challenge", "bits", "expires_in"}</code>. Solve for a
<code>nonce</code> such that
<code>SHA-256(challenge + "." + nonce)</code> has <code>bits</code>
leading zero bits</td>
</tr>
<tr>
<td><code>POST /e2e/anon/presence</code></td>
+12 -3
View File
@@ -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: 2e6507a842e6c013 -->
source-sha256: 5f1afde0526529d4 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -495,8 +495,17 @@ sign-up buckets.</td>
account, so one network cannot spend an account's whole hour. A crowd of
networks still can: an account being flooded with introductions stops
getting new ones for the hour, and the answer for that is Unlisted plus
contact links. Separately, at most 90 unspent intro tokens (30
introductions) per account at once.</td>
contact links. Separately, at most 30 unspent introductions (one token
each) per account at once.</td>
</tr>
<tr>
<td><code>FN_INTRO_POW_BITS</code></td>
<td><code>16</code></td>
<td>Leading zero bits of SHA-256 work an introduction by handle must
show (decision DD2), from <code>GET /e2e/intro/challenge</code>; each
solution is accepted once. Capped at <code>24</code>. <code>0</code>
switches it off. An introduction by contact link needs none: the link is
the permission.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_RATE</code></td>
+4
View File
@@ -66,6 +66,9 @@ struct Config: Sendable {
var introRatePerIP: Int = 30
var introRatePerNetwork: Int = 240
var introRatePerTarget: Int = 30
/// Leading zero bits of work asked of an introduction by handle (DD2).
/// 0 switches it off. Links need none.
var introProofOfWorkBits: Int = 16
/// Sealed envelopes from one address to one recipient per hour. The
/// recipient-wide `FN_SEND_RATE` bucket now counts only REFUSED sealed
@@ -358,6 +361,7 @@ struct Config: Sendable {
config.introRatePerIP = int("FN_INTRO_RATE", 30)
config.introRatePerNetwork = int("FN_INTRO_RATE_NETWORK", 240)
config.introRatePerTarget = int("FN_INTRO_RATE_TARGET", 30)
config.introProofOfWorkBits = min(max(int("FN_INTRO_POW_BITS", 16), 0), SignupChallenge.maxBits)
// A tenth of the recipient-wide rate unless set.
config.sendRatePerRecipientIP = int(
"FN_SEND_RATE_RECIPIENT_IP", max(1, config.sendRatePerHour / 10))
+3
View File
@@ -1230,6 +1230,9 @@ func register(routes r: Routes, on app: Application) throws {
e2e.post("intro") { req in
try await r.introduce(req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
}
e2e.get("intro", "challenge") { req in
try await r.introChallenge(req, now: unixNow())
}
e2e.post("anon", "presence") { req in
try await r.anonymousPresence(
req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
+76 -33
View File
@@ -8,18 +8,14 @@ import Vapor
/// certificates (DC4), and contact links (design B, stage B2).
extension Routes {
/// Tokens handed out per introduction. One opens one sealed submission,
/// and a first message plus a follow-up or two before the recipient's own
/// batch arrives is what a stranger needs.
static let introTokensPerIntro = 3
/// Seconds an introduction's tokens stay spendable.
/// Seconds an introduction's token stays spendable.
static let introLifetime = 600
/// Unspent, unexpired introduction tokens one account may have waiting.
/// Thirty introductions' worth: a burst of strangers fills it and then
/// waits ten minutes, it does not build up.
static let maxOutstandingIntroTokens = 90
/// Unspent, unexpired introductions one account may have waiting. One
/// token each (§14.5): the first message's prelude hands the recipient
/// what the reply needs, so a stranger never needs a second. A burst of
/// strangers fills this and then waits ten minutes; it does not build up.
static let maxOutstandingIntros = 30
/// Contact links one account may hold at once (DB3).
static let maxContactLinks = 5
@@ -58,6 +54,10 @@ extension Routes {
guard (handle == nil) != (linkHash == nil) else {
throw WireError.badRequest("Send exactly one of handle or link.")
}
// A handle costs a little work (DD2); a link is its own permission.
// Checked before anything is looked up or charged, and the same for
// every handle, so it says nothing about the name.
if handle != nil { try await requireIntroProofOfWork(input, now: now) }
// Charged before anything is looked up, and the same charge for every
// answer, so the budget cannot tell a listed name from the rest.
@@ -116,28 +116,22 @@ extension Routes {
}
let waiting = try await IntroToken.query(on: request.db)
.filter(\.$userID == targetID).filter(\.$expiresAt > now).count()
guard waiting + Self.introTokensPerIntro <= Self.maxOutstandingIntroTokens else {
guard waiting < Self.maxOutstandingIntros else {
throw WireError.rateLimited(
"This account has too many introductions waiting. Try again later.",
retryAfter: Self.introLifetime)
}
let tokens = (0..<Self.introTokensPerIntro).map { _ in
SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
}
let token = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
let expires = now + Self.introLifetime
try await request.db.retryingTransaction { db in
for token in tokens {
let row = IntroToken()
row.userID = targetID
row.tokenHash = Data(SHA256.hash(data: token)).base64EncodedString()
row.expiresAt = expires
try await row.create(on: db)
}
}
let row = IntroToken()
row.userID = targetID
row.tokenHash = Data(SHA256.hash(data: token)).base64EncodedString()
row.expiresAt = expires
try await row.create(on: request.db)
// Popped after the tokens exist: a failure here leaves tokens that
// expire unused, never prekeys gone with nothing to show for them.
// Popped after the token exists: a failure here leaves a token that
// expires unused, never prekeys gone with nothing to show for them.
guard let bundles = try await bundleList(userID: targetID, on: request) else {
throw Self.introNotFound
}
@@ -145,9 +139,8 @@ extension Routes {
IntroResponse(
user_id: targetID,
fnid: target.fnid,
intro: tokens[0].base64EncodedString(),
intro: token.base64EncodedString(),
exp: expires,
intro_tokens: tokens.map { $0.base64EncodedString() },
expires_in: Self.introLifetime,
handle_cert: HandleCertificate.issue(
audience: config.audience, userID: targetID, handle: target.username,
@@ -155,6 +148,54 @@ extension Routes {
devices: bundles.devices))
}
// MARK: - Proof of work for an introduction by handle (DD2)
/// The secret the intro challenge MAC is keyed with. Derived like the
/// sign-up one but with its own label, so a sign-up challenge is not an
/// intro challenge; random per process on a relay with neither key, which
/// only means a challenge does not survive a restart there.
private static let fallbackChallengeSecret = SymmetricKey(size: .bits256)
.withUnsafeBytes { Data($0) }
var introChallengeSecret: Data {
if let seed = config.issuerSigningKeyRaw ?? config.federationKeyRaw, !seed.isEmpty {
return Data(SHA256.hash(data: seed + Data("intro-pow".utf8)))
}
return Self.fallbackChallengeSecret
}
/// `GET /e2e/intro/challenge`: a puzzle in the sign-up challenge's format
/// (`SignupChallenge`), at `FN_INTRO_POW_BITS`.
func introChallenge(_ request: Request, now: Int) async throws -> Response {
try .json(
SignupChallenge.issue(
secret: introChallengeSecret, now: now, bits: config.introProofOfWorkBits))
}
/// Refuse an introduction by handle that has not done the work, or that
/// presents a solution already spent. Single use through the hourly
/// limiter: a key per challenge with a limit of one, which outlives the
/// challenge's ten minutes.
func requireIntroProofOfWork(_ input: IntroRequest, now: Int) async throws {
let bits = config.introProofOfWorkBits
guard bits > 0 else { return }
guard let challenge = input.challenge, let nonce = input.nonce else {
throw WireError(
"challenge_required",
"Looking somebody up by username needs a moment of work first. "
+ "Ask GET /e2e/intro/challenge and send its answer.", 400)
}
let failed = WireError(
"challenge_failed",
"That took too long, or the check did not come out right. Please try again.", 400)
do {
try SignupChallenge.verify(
challenge: challenge, nonce: nonce, secret: introChallengeSecret,
now: now, bits: bits)
} catch { throw failed }
let spent = "intro:pow:\(Data(SHA256.hash(data: Data(challenge.utf8))).prefix(12).map { String(format: "%02x", $0) }.joined())"
guard await limits.consume(spent, limit: 1, now: now) else { throw failed }
}
// MARK: - GET /e2e/handle-cert
/// A certificate for the caller's own handle, to carry on a first message
@@ -251,19 +292,21 @@ struct IntroRequest: Decodable {
let handle: String?
/// A contact link's secret, base64 or base64url, 32 bytes.
let link: String?
/// For a handle: a challenge from `GET /e2e/intro/challenge` and the
/// nonce that solves it.
var challenge: String? = nil
var nonce: String? = nil
}
struct IntroResponse: Content {
let user_id: Int
let fnid: String?
/// §14.5's shape: one token and its expiry in unix seconds. The first of
/// `intro_tokens`, so a client written to the spec spends that one and
/// the rest expire unused.
/// §14.5: a base64 32-byte token good for one sealed submission to
/// `user_id` (as `intro` or `delivery_token`), and its expiry in unix
/// seconds.
let intro: String
let exp: Int
/// Base64 32-byte tokens, each good for one sealed submission to
/// `user_id`, presented as `delivery_token`.
let intro_tokens: [String]
/// Seconds until `exp`, for a client whose clock is not the relay's.
let expires_in: Int
/// Nil only on a relay with no signing key.
let handle_cert: HandleCertificate?
+130 -24
View File
@@ -14,7 +14,7 @@ struct IntroductionTests {
private let now = W0B.now
@Test("an introduction by handle returns tokens, bundles and a certificate")
@Test("an introduction by handle returns one token, bundles and a certificate")
func byHandle() async throws {
try await withRelay(signing: true) { h in
try await W0B.account(h, id: 7, name: "ada")
@@ -23,19 +23,15 @@ struct IntroductionTests {
#expect(r.status == .ok)
let body = try W0B.decode(IntroResponse.self, r)
#expect(body.user_id == 7)
#expect(body.intro_tokens.count == Routes.introTokensPerIntro)
#expect(body.expires_in == 600)
// §14.5's shape alongside: the first token and its expiry.
#expect(body.intro == body.intro_tokens[0] && body.exp == now + 600)
#expect(body.expires_in == 600 && body.exp == now + 600)
#expect(Data(base64Encoded: body.intro)?.count == 32)
#expect(body.devices.map(\.device_id) == ["aaaa-0007"])
#expect(body.devices.first?.opk?.id == 1, "the bundle did not pop a prekey")
#expect(try await Routes.poolSize(PreKey.self, "aaaa-0007", on: h.db) == 1)
// Rows: the hashes, the account, the expiry, and nothing else.
// One row: the hash, the account, the expiry, and nothing else.
let rows = try await IntroToken.query(on: h.db).all()
#expect(Set(rows.map(\.tokenHash)) == Set(body.intro_tokens.map {
W0B.sha(Data(base64Encoded: $0)!)
}))
#expect(rows.map(\.tokenHash) == [W0B.sha(Data(base64Encoded: body.intro)!)])
#expect(rows.allSatisfy { $0.userID == 7 && $0.expiresAt == now + 600 })
let cert = try #require(body.handle_cert)
@@ -50,7 +46,7 @@ struct IntroductionTests {
try await W0B.account(h, id: 7, name: "ada")
try await W0B.device(h, user: 7, id: "aaaa-0007")
let body = try W0B.decode(IntroResponse.self, try await W0B.intro(h, handle: "ada"))
let token = body.intro_tokens[0]
let token = body.intro
let first = try await W0B.sealedSend(h, to: 7, devices: ["aaaa-0007"], token: token)
#expect(first.status == .ok)
@@ -58,7 +54,7 @@ struct IntroductionTests {
try await W0B.sealedSend(h, to: 7, devices: ["aaaa-0007"], token: token)
}
#expect(again.status == 401, "a spent intro token was accepted twice")
#expect(try await IntroToken.query(on: h.db).count() == 2)
#expect(try await IntroToken.query(on: h.db).count() == 0)
let stored = try await Envelope.query(on: h.db).all()
#expect(stored.count == 1)
@@ -98,7 +94,7 @@ struct IntroductionTests {
let body = try W0B.decode(IntroResponse.self, try await W0B.intro(h, handle: "ada"))
let wrong = await W0B.answer {
try await W0B.sealedSend(
h, to: 8, devices: ["bbbb-0008"], token: body.intro_tokens[0])
h, to: 8, devices: ["bbbb-0008"], token: body.intro)
}
#expect(wrong.status == 401)
}
@@ -111,21 +107,22 @@ struct IntroductionTests {
try await W0B.device(h, user: 7, id: "aaaa-0007")
// Minted long enough ago that the sweep's wall clock is past it.
let minted = Int(Date().timeIntervalSince1970) - 3600
let body = try W0B.decode(
let first = try W0B.decode(
IntroResponse.self, try await W0B.intro(h, handle: "ada", now: minted))
let second = try W0B.decode(
IntroResponse.self,
try await W0B.intro(h, handle: "ada", ip: "203.0.113.4", now: minted))
let late = await W0B.answer {
try await W0B.sealedSend(
h, to: 7, devices: ["aaaa-0007"], token: body.intro_tokens[0],
now: minted + 600)
h, to: 7, devices: ["aaaa-0007"], token: first.intro, now: minted + 600)
}
#expect(late.status == 401, "an expired intro token was accepted")
// Not spent by the refusal: the row stays for the sweep.
#expect(try await IntroToken.query(on: h.db).count() == 3)
#expect(try await IntroToken.query(on: h.db).count() == 2)
let inTime = try await W0B.sealedSend(
h, to: 7, devices: ["aaaa-0007"], token: body.intro_tokens[1],
now: minted + 599)
h, to: 7, devices: ["aaaa-0007"], token: second.intro, now: minted + 599)
#expect(inTime.status == .ok)
try await Retention(
@@ -148,7 +145,7 @@ struct IntroductionTests {
attachmentStore: h.routes.attachmentStore,
sendMetrics: SendMetrics(), redis: nil
).sweepOnce()
#expect(try await IntroToken.query(on: h.db).count() == 3)
#expect(try await IntroToken.query(on: h.db).count() == 1)
}
}
@@ -210,7 +207,9 @@ struct IntroductionTests {
try await withRelay(env: ["FN_INTRO_RATE_TARGET": "1000"]) { h in
try await W0B.account(h, id: 7, name: "ada")
try await W0B.device(h, user: 7, id: "aaaa-0007", prekeys: 0)
let fits = Routes.maxOutstandingIntroTokens / Routes.introTokensPerIntro
// Thirty, written out, so the test does not move with the constant.
#expect(Routes.maxOutstandingIntros == 30)
let fits = 30
for n in 0..<fits {
let r = try await W0B.intro(h, handle: "ada", ip: "10.\(n).0.1")
#expect(r.status == .ok)
@@ -243,7 +242,7 @@ struct IntroductionTests {
let start = DispatchTime.now().uptimeNanoseconds
let a = await W0B.answer { try await W0B.intro(h, handle: handle) }
let took = DispatchTime.now().uptimeNanoseconds - start
return (a, took, Array(spy.keys[before...]))
return (a, took, W0B.sameShape(Array(spy.keys[before...])))
}
let (unlisted, t1, k1) = await timed("ada")
let (missing, t2, k2) = await timed("nobody")
@@ -311,9 +310,9 @@ struct IntroductionTests {
try await W0B.account(a, id: 7, name: "ada")
try await W0B.device(a, user: 7, id: "aaaa-0007", prekeys: 0)
var tokens: [String] = []
for n in 0..<8 {
for n in 0..<24 {
let r = try await W0B.intro(a, handle: "ada", ip: "10.\(n).0.1")
tokens += try W0B.decode(IntroResponse.self, r).intro_tokens
tokens.append(try W0B.decode(IntroResponse.self, r).intro)
}
let accepted = try await withThrowingTaskGroup(of: Int.self) { group in
for token in tokens {
@@ -345,7 +344,7 @@ struct IntroductionTests {
let body = try W0B.decode(
IntroResponse.self, try await W0B.intro(h, handle: "ada", ip: asker))
_ = try await W0B.sealedSend(
h, to: 7, devices: ["aaaa-0007"], token: body.intro_tokens[0], ip: asker)
h, to: 7, devices: ["aaaa-0007"], token: body.intro, ip: asker)
let sql = try #require(h.db as? any SQLDatabase)
struct Column: Decodable { let name: String }
@@ -425,3 +424,110 @@ struct IntroductionTests {
}
}
}
/// DD2: an introduction by handle costs a little work; one by link costs none.
@Suite("introduction proof of work")
struct IntroProofOfWorkTests {
private let now = W0B.now
private func setUp(_ h: RelayHarness) async throws {
try await W0B.account(h, id: 7, name: "ada")
try await W0B.account(h, id: 8, name: "hidden", listed: false)
try await W0B.device(h, user: 7, id: "aaaa-0007")
}
@Test("the default is 16 bits, and a challenge says so")
func defaultBits() async throws {
let config = try Config.fromEnvironment([
"FN_ISSUER_PUBLIC_KEY": Data(repeating: 0, count: 32).base64EncodedString(),
"FN_RELAY_AUDIENCE": "x",
])
#expect(config.introProofOfWorkBits == 16)
try await withRelay(env: ["FN_INTRO_POW_BITS": "16"]) { h in
let r = try await h.routes.introChallenge(
h.anonymousRequest(.GET, "/e2e/intro/challenge"), now: now)
#expect(try W0B.decode(SignupChallenge.Issued.self, r).bits == 16)
}
}
@Test("no work, no introduction by handle, and the refusal is the same for every name")
func required() async throws {
try await withRelay { h in
try await setUp(h)
let listed = await W0B.answer { try await W0B.intro(h, handle: "ada", work: .none) }
let unlisted = await W0B.answer {
try await W0B.intro(h, handle: "hidden", work: .none)
}
#expect(listed.status == 400 && listed.body.contains("challenge_required"))
#expect(listed == unlisted)
#expect(try await IntroToken.query(on: h.db).count() == 0)
}
}
@Test("a wrong answer, a spent answer, another relay's challenge and an old one are refused")
func refused() async throws {
try await withRelay { h in
try await setUp(h)
let (c, n) = try await W0B.solved(h)
// Provably wrong for this challenge at the relay's bits.
let wrong = try #require(
(0..<10_000).lazy.map { "w\($0)" }.first {
SignupChallenge.leadingZeroBits(
of: SHA256.hash(data: Data("\(c).\($0)".utf8)))
< h.routes.config.introProofOfWorkBits
})
let bad = await W0B.answer {
try await W0B.intro(h, handle: "ada", work: .given(challenge: c, nonce: wrong))
}
#expect(bad.status == 400 && bad.body.contains("challenge_failed"))
let first = try await W0B.intro(h, handle: "ada", work: .given(challenge: c, nonce: n))
#expect(first.status == .ok)
let again = await W0B.answer {
try await W0B.intro(
h, handle: "ada", ip: "203.0.113.8", work: .given(challenge: c, nonce: n))
}
#expect(again.status == 400, "one solution introduced twice")
let foreign = SignupChallenge.issue(
secret: Data(repeating: 9, count: 32), now: now,
bits: h.routes.config.introProofOfWorkBits)
let fn = try #require(
SignupChallenge.solve(challenge: foreign.challenge, bits: foreign.bits))
let other = await W0B.answer {
try await W0B.intro(h, handle: "ada", work: .given(challenge: foreign.challenge, nonce: fn))
}
#expect(other.status == 400, "a challenge this relay did not issue was accepted")
let (oc, on) = try await W0B.solved(h, now: now - 601)
let old = await W0B.answer {
try await W0B.intro(h, handle: "ada", work: .given(challenge: oc, nonce: on))
}
#expect(old.status == 400, "an expired challenge was accepted")
}
}
@Test("an introduction by link needs no work")
func linkNeedsNone() async throws {
try await withRelay { h in
try await setUp(h)
let secret = W0B.random32()
let link = ContactLink()
link.userID = 7
link.linkHash = W0B.sha(secret)
try await link.create(on: h.db)
let r = try await W0B.intro(h, link: base64URLEncode(secret))
#expect(r.status == .ok)
}
}
@Test("zero switches the work off")
func off() async throws {
try await withRelay(env: ["FN_INTRO_POW_BITS": "0"]) { h in
try await setUp(h)
let r = try await W0B.intro(h, handle: "ada", work: .none)
#expect(r.status == .ok)
}
}
}
@@ -69,6 +69,11 @@ struct NameDoorTimingTests {
}
let a = W0B.accounts(h)
let m = W0B.mobile(h)
// Introductions by handle carry a solved challenge. Solved here,
// outside the clock, one per request, since each is single use.
var work: [(String, String)] = []
for _ in 0..<(2 * samples) { work.append(try await W0B.solved(h)) }
let solutions = work
let doors: [(String, @Sendable (String, Int) async -> Void)] = [
("lookup ?slug=", { name, _ in
@@ -93,7 +98,10 @@ struct NameDoorTimingTests {
userID: id)
}),
("POST /e2e/intro {handle}", { name, i in
_ = try? await W0B.intro(h, handle: name, ip: "10.\(i % 250).\(i / 250).1")
let (c, n) = solutions[2 * i + (name == "hidden" ? 0 : 1)]
_ = try? await W0B.intro(
h, handle: name, ip: "10.\(i % 250).\(i / 250).1",
work: .given(challenge: c, nonce: n))
}),
]
@@ -40,6 +40,10 @@ struct RelayHarness: @unchecked Sendable {
if signing {
env["FN_ISSUER_PRIVATE_KEY"] = issuer.rawRepresentation.base64EncodedString()
}
// The introduction proof of work at 8 bits rather than the default
// 16: suites make dozens of introductions, and the work is not what
// they test. `IntroProofOfWorkTests` checks the default itself.
env["FN_INTRO_POW_BITS"] = "8"
for (k, v) in extra { env[k] = v }
let config = try Config.fromEnvironment(env)
+1 -1
View File
@@ -102,7 +102,7 @@ struct UnlistedTests {
let answer = await door(name)
return (
answer, DispatchTime.now().uptimeNanoseconds - start,
Array(spy.keys[before...])
W0B.sameShape(Array(spy.keys[before...]))
)
}
let (hidden, t1, k1) = await run("hidden")
@@ -121,14 +121,44 @@ enum W0B {
return String(decoding: data, as: UTF8.self)
}
/// POST /e2e/intro.
/// How an introduction by handle answers the proof of work.
enum Work {
/// Ask for a challenge and solve it, as a client would.
case solve
/// Send none.
case none
/// This exact challenge and nonce.
case given(challenge: String, nonce: String)
}
/// A challenge from the relay, solved, at the relay's configured bits.
static func solved(_ h: RelayHarness, now: Int = now) async throws -> (String, String) {
let r = try await h.routes.introChallenge(
h.anonymousRequest(.GET, "/e2e/intro/challenge"), now: now)
let issued = try decode(SignupChallenge.Issued.self, r)
let nonce = try #require(SignupChallenge.solve(challenge: issued.challenge, bits: issued.bits))
return (issued.challenge, nonce)
}
/// POST /e2e/intro. A handle introduction solves the relay's challenge
/// unless told otherwise; a link one sends none.
static func intro(
_ h: RelayHarness, handle: String? = nil, link: String? = nil,
ip: String = "198.51.100.7", now: Int = now
ip: String = "198.51.100.7", now: Int = now, work: Work = .solve
) async throws -> Response {
var fields: [String] = []
if let handle { fields.append("\"handle\":\"\(handle)\"") }
if let link { fields.append("\"link\":\"\(link)\"") }
if handle != nil {
switch work {
case .none: break
case .solve:
let (c, n) = try await solved(h, now: now)
fields.append("\"challenge\":\"\(c)\",\"nonce\":\"\(n)\"")
case .given(let c, let n):
fields.append("\"challenge\":\"\(c)\",\"nonce\":\"\(n)\"")
}
}
let body = Data("{\(fields.joined(separator: ","))}".utf8)
let req = h.anonymousRequest(.POST, "/e2e/intro", body: body)
return try await h.routes.introduce(req, body: body, clientIP: ip, now: now)
@@ -151,6 +181,13 @@ enum W0B {
return try await h.routes.send(req, body: body, clientIP: ip, now: now)
}
/// Meter keys with the per-challenge one folded: each introduction by
/// handle spends its own solved challenge, so that key differs every time
/// by construction and says nothing about the name.
static func sameShape(_ keys: [String]) -> [String] {
keys.map { $0.hasPrefix("intro:pow:") ? "intro:pow:*" : $0 }
}
static func random32() -> Data {
SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
}
+15 -17
View File
@@ -166,21 +166,6 @@ enum WaveZeroBUpgrade {
try await fresh.create(on: app.db)
#expect(try await Discoverability.isListed(try #require(fresh.id), on: app.db))
// The column reverts and applies again, and comes back listed.
//
// On ONE connection. SQLite checks "does this column exist" for
// ALTER TABLE while parsing, against the schema that connection last
// loaded, and only notices a change made by another connection when
// a statement runs. So a DROP on one pooled connection followed by an
// ADD on another fails "duplicate column name", or the other way
// round "no such column", though the table is fine. It cannot happen
// at boot, where migrations run one after another and nothing reverts.
try await app.db.withConnection { conn in
try await AddUserDiscoverable().revert(on: conn)
try await AddUserDiscoverable().prepare(on: conn)
}
#expect(try await Discoverability.isListed(oldID, on: app.db), "re-added as listed")
// The new tables take rows, and an intro token is spent once.
let raw = Data(repeating: 9, count: 32)
let intro = IntroToken()
@@ -197,14 +182,27 @@ enum WaveZeroBUpgrade {
// The delivery token from before the wave is still spendable.
#expect(try await DeliveryToken.query(on: app.db).count() == 1)
// The tables revert and apply again, and nothing else goes.
// Last, and every step on ONE connection: the three migrations revert
// and apply again, the column comes back listed, and nothing else goes.
//
// SQLite resolves column and table names while PARSING a statement,
// against the schema that connection last loaded, and only notices a
// change made by another connection when a statement runs. So DDL on
// one pooled connection followed by a statement on another can fail
// "duplicate column name" or "no such column" though the table is
// fine. It cannot happen at boot, where migrations run one after
// another and nothing reverts; it did happen here, so no statement
// after this block goes to another connection.
try await app.db.withConnection { conn in
try await AddUserDiscoverable().revert(on: conn)
try await AddUserDiscoverable().prepare(on: conn)
#expect(try await Discoverability.isListed(oldID, on: conn), "re-added as listed")
try await CreateContactLinks().revert(on: conn)
try await CreateContactLinks().prepare(on: conn)
try await CreateIntroTokens().revert(on: conn)
try await CreateIntroTokens().prepare(on: conn)
#expect(try await User.query(on: conn).count() == 3)
}
#expect(try await User.query(on: app.db).count() == 3)
}
}
+2 -1
View File
@@ -183,7 +183,8 @@ it, never the id. The relay still sees the address.
| Route | Purpose |
|---|---|
| `POST /e2e/intro` | `{"handle": h}` (listed accounts only) or `{"link": secret}` (base64 or base64url, 32 bytes). Returns `user_id`, `fnid`, `intro` and `exp` (§14.5's shape: one token and its expiry in unix seconds), and `intro_tokens`, three tokens of which `intro` is the first, each good for one sealed send within 10 minutes (`expires_in`), the target's bundles (`devices`, one-time prekeys popped) and `handle_cert`. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same `404 not_found` "No such account.", no sooner than 5 ms. Metered per address (`FN_INTRO_RATE`), per network (`FN_INTRO_RATE_NETWORK`), and once an account is found, per account (`FN_INTRO_RATE_TARGET`) and per network and account (a third of that); at most 90 unspent intro tokens per account |
| `POST /e2e/intro` | `{"handle": h, "challenge": c, "nonce": n}` (listed accounts only) or `{"link": secret}` (base64 or base64url, 32 bytes). A handle needs a solved challenge from `GET /e2e/intro/challenge` (`FN_INTRO_POW_BITS`, each solution good once; `400 challenge_required` or `challenge_failed` otherwise, the same for every name); a link needs none. Returns `user_id`, `fnid`, `intro`, one token good for one sealed send (as `intro` or `delivery_token`) until `exp` in unix seconds, 10 minutes ahead (`expires_in`), the target's bundles (`devices`, one-time prekeys popped) and `handle_cert`. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same `404 not_found` "No such account.", no sooner than 5 ms. Metered per address (`FN_INTRO_RATE`), per network (`FN_INTRO_RATE_NETWORK`), and once an account is found, per account (`FN_INTRO_RATE_TARGET`) and per network and account (a third of that); at most 30 unspent introductions per account |
| `GET /e2e/intro/challenge` | A proof-of-work challenge for an introduction by handle, in the sign-up challenge's format: `{"challenge", "bits", "expires_in"}`. Solve for a `nonce` such that `SHA-256(challenge + "." + nonce)` has `bits` leading zero bits |
| `POST /e2e/anon/presence` | `{"tokens": [...]}`, at most 8. The same answer as `POST /messenger/presence`. Metered per address, by token count |
| `POST /e2e/anon/devices` | `{"user_id": n, "cap": token?}`. Identity keys and `caps_sig` only, no `created_at` or `last_seen` |
| `POST /e2e/anon/bundles` | `{"user_id": n, "cap": token?}`. As `GET /e2e/keys/{user}`, popping a prekey per device |
+2 -1
View File
@@ -155,7 +155,8 @@ Defaults are sane; raise them only with a reason.
| `FN_SEND_RATE_RECIPIENT_IP` | `FN_SEND_RATE` / 10 | Sealed envelopes from one address to one recipient. What bounds a sender with a working key now that accepted sends no longer fill the recipient-wide bucket, and what stops one address filling that bucket with refusals on its own. Keyed by a keyed hash of the recipient, never the account id. |
| `FN_INTRO_RATE` | `30`/hr | Introductions (`POST /e2e/intro`) per address. Charged before the handle or link is looked up, so a listed name, an unlisted one and an unknown one cost the same. |
| `FN_INTRO_RATE_NETWORK` | `240`/hr | Introductions per network, a /24 for IPv4 and a /48 for IPv6, the sign-up buckets. |
| `FN_INTRO_RATE_TARGET` | `30`/hr | Introductions per account, and a third of it per network and account, so one network cannot spend an account's whole hour. A crowd of networks still can: an account being flooded with introductions stops getting new ones for the hour, and the answer for that is Unlisted plus contact links. Separately, at most 90 unspent intro tokens (30 introductions) per account at once. |
| `FN_INTRO_RATE_TARGET` | `30`/hr | Introductions per account, and a third of it per network and account, so one network cannot spend an account's whole hour. A crowd of networks still can: an account being flooded with introductions stops getting new ones for the hour, and the answer for that is Unlisted plus contact links. Separately, at most 30 unspent introductions (one token each) per account at once. |
| `FN_INTRO_POW_BITS` | `16` | Leading zero bits of SHA-256 work an introduction by handle must show (decision DD2), from `GET /e2e/intro/challenge`; each solution is accepted once. Capped at `24`. `0` switches it off. An introduction by contact link needs none: the link is the permission. |
| `FN_ATTACHMENT_RATE` | `60`/hr | |
| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |
| `FN_ATTACHMENT_MAX_BYTES` | `10 MiB` | Per blob. |
+3 -3
View File
@@ -97,15 +97,15 @@ is one snapshot of a day's mail and the summed counters are the real reading.
A first message to somebody went unsealed, so the envelope named its sender,
because the sender held none of the recipient's capabilities yet.
`POST /e2e/intro` (anonymous, target in the body) now hands a stranger three
single-use tokens for ten minutes. New table `intro_tokens`: token hash,
`POST /e2e/intro` (anonymous, target in the body) now hands a stranger one
single-use token for ten minutes (by handle, after a small proof of work). New table `intro_tokens`: token hash,
account, expiry. Nothing about the asker, and a test reads every row of every
table after an introduction and a send and finds the asker's address in none.
Rows go when spent or when the hourly sweep finds them expired (`intros` on
the sweep line).
What this adds to a dump: for each account, how many introductions are
waiting right now (at most 90 tokens, 30 introductions). It does not say to
waiting right now (at most 30). It does not say to
whom or from where.
The sealed path's recipient bucket used to count every accepted envelope, so