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:
co-authored by
Claude Opus 5.5
parent
b4bf14587b
commit
78b0a576d5
+29
-16
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user