Wave 0b: introductions, Unlisted, contact links, anonymous reads
Additive and reversible. Three new migrations appended to the boot list
(CreateIntroTokens, CreateContactLinks, AddUserDiscoverable); nothing
existing is altered or dropped, and key_log is not touched.
C1, introductions. Anonymous POST /e2e/intro {handle} or {link} returns
three single-use tokens good for ten minutes, the target's bundles (one-time
prekeys popped, as GET /e2e/keys does) and a handle certificate.
intro_tokens holds (token_hash, user_id, expires_at) and nothing about who
asked; the sweep deletes expired rows. Metered per address, per network
(sign-up's /24 and /48), per account and per (network, account), and capped
at 90 unspent tokens per account. spendDeliveryToken tries delivery_tokens
then intro_tokens, each a single DELETE ... RETURNING. Handle certificates
sign "fnhandle-v1|audience|user_id|handle|exp" with the issuer signing key
whose public half both apps pin (RelayIdentityPin); GET /e2e/handle-cert
returns the caller's own.
The sealed send path: the recipient-wide bucket now counts only refused
attempts (so a working delivery key cannot be used to lock everyone else
out), plus a per-(recipient, address) bucket (FN_SEND_RATE_RECIPIENT_IP,
default a tenth of FN_SEND_RATE). Meters about an account are keyed by a
keyed hash (LimiterKeys), never the account id.
B1/B2, Unlisted. users.discoverable, default true, set by PUT /messenger/me
{"discoverable": false}. Read and written with raw SQL, not through the User
model, so migrations that load User early in the list still build a fresh
database. lookup ?slug=, POST /messenger/buddies, GET
/e2e/account/identity/:handle, GET /messenger/users/:id and POST /e2e/intro
{handle} answer an unlisted name as a missing one: same status and body,
same meter charge, and every "not there" answer held to a 5 ms floor.
Contact links: POST /e2e/contact-links {add, revoke} by hash, at most five,
refused (409) past that, table holds only (user_id, link_hash).
D1, anonymous reads. POST /e2e/anon/presence, /e2e/anon/devices and
/e2e/anon/bundles, targets in the body, no credential read. Directory and
bundles are metered by the subject's presence token when the caller holds
one, otherwise per address. The public directory (other accounts, federated
peers, the anonymous route) no longer carries created_at or last_seen; GET
/e2e/devices/{own id} keeps them because both apps' Devices screens read
created_at there, and GET /e2e/devices/me is the new home for them.
presenceByToken is split from caller().
Also: account deletion now removes the account's delivery_tokens (left
behind before), intro_tokens and contact_links.
Tests: 845 swift-testing tests in 93 suites and 61 XCTest pass, Postgres
18 suite included (fresh and populated upgrade for wave 0b, and a
concurrent intro-spend race). 23 falsifications, each red on its own
sabotage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
378de4f6d7
commit
4095ca60ec
@@ -26,7 +26,7 @@ set -uo pipefail
|
||||
TABLES="buddies conversation_state notifications group_members groups users
|
||||
delivery_keys key_log push_tokens devices blocks app_credentials
|
||||
group_tags roster_tags device_delivery_keys device_roster_tags prekeys pq_prekeys
|
||||
delivery_tokens envelopes attachments"
|
||||
delivery_tokens intro_tokens contact_links envelopes attachments"
|
||||
|
||||
before=$(docker exec messenger-db-1 psql -U flamenet -d flamenet -tAc \
|
||||
"SELECT COALESCE(sum(n_dead_tup),0) FROM pg_stat_user_tables;" 2>/dev/null)
|
||||
|
||||
@@ -60,6 +60,19 @@ struct Config: Sendable {
|
||||
/// until those have left the fleet.
|
||||
var padAttachments: Bool = false
|
||||
|
||||
/// Introductions (`POST /e2e/intro`) per address, per network (a /24 or
|
||||
/// a /48, as for sign-up) and per target account, per hour. See
|
||||
/// `Routes.introduce`.
|
||||
var introRatePerIP: Int = 30
|
||||
var introRatePerNetwork: Int = 240
|
||||
var introRatePerTarget: Int = 30
|
||||
|
||||
/// Sealed envelopes from one address to one recipient per hour. The
|
||||
/// recipient-wide `FN_SEND_RATE` bucket now counts only REFUSED sealed
|
||||
/// attempts, so somebody holding a working delivery key cannot use it up
|
||||
/// and lock everybody else out; this is what bounds that somebody instead.
|
||||
var sendRatePerRecipientIP: Int = 300
|
||||
|
||||
/// Whether this relay will exchange traffic with other relays.
|
||||
let federationPolicy: String
|
||||
|
||||
@@ -342,6 +355,12 @@ struct Config: Sendable {
|
||||
)
|
||||
config.padEnforce = PadEnforcement(setting: env["FN_PAD_ENFORCE"])
|
||||
config.padAttachments = bool("FN_PAD_ATTACHMENTS", false)
|
||||
config.introRatePerIP = int("FN_INTRO_RATE", 30)
|
||||
config.introRatePerNetwork = int("FN_INTRO_RATE_NETWORK", 240)
|
||||
config.introRatePerTarget = int("FN_INTRO_RATE_TARGET", 30)
|
||||
// A tenth of the recipient-wide rate unless set.
|
||||
config.sendRatePerRecipientIP = int(
|
||||
"FN_SEND_RATE_RECIPIENT_IP", max(1, config.sendRatePerHour / 10))
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
@@ -1225,6 +1225,34 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
try await r.meterLookup(req, now: unixNow())
|
||||
return try await r.resolveIdentity(req, handle: handle)
|
||||
}
|
||||
// Wave 0b (2026-10-07). Anonymous: no credential is read, and the
|
||||
// target is in the body so it reaches no access log.
|
||||
e2e.post("intro") { req in
|
||||
try await r.introduce(req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
|
||||
}
|
||||
e2e.post("anon", "presence") { req in
|
||||
try await r.anonymousPresence(
|
||||
req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
|
||||
}
|
||||
e2e.post("anon", "devices") { req in
|
||||
try await r.anonymousDevices(
|
||||
req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
|
||||
}
|
||||
e2e.post("anon", "bundles") { req in
|
||||
try await r.anonymousBundles(
|
||||
req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
|
||||
}
|
||||
// Authenticated: the caller's own certificate, links and devices.
|
||||
e2e.get("handle-cert") { req in
|
||||
try await r.ownHandleCertificate(req, now: unixNow())
|
||||
}
|
||||
e2e.post("contact-links") { req in
|
||||
try await r.setContactLinks(req, body: body(of: req), now: unixNow())
|
||||
}
|
||||
// A constant segment, so the router takes it before `:user_id`.
|
||||
e2e.get("devices", "me") { req in
|
||||
try await r.ownDevices(req, now: unixNow())
|
||||
}
|
||||
e2e.get("keys", ":user_id") { req in
|
||||
guard let target = req.parameters.get("user_id", as: Int.self) else {
|
||||
throw WireError.badRequest("Malformed user id.")
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// A short-lived, single-use capability to place one sealed envelope in an
|
||||
/// inbox, minted by the relay for somebody who has no other way in yet
|
||||
/// (design C, stage C1).
|
||||
///
|
||||
/// The recipient's own delivery tokens (§21.4) only reach people the recipient
|
||||
/// has already handed a batch to. A first message has nobody to hand it, so
|
||||
/// it went unsealed and named its sender. An introduction is the relay handing
|
||||
/// the stranger a few tokens instead, metered so it cannot be farmed.
|
||||
///
|
||||
/// Three columns and nothing else. In particular nothing records who asked:
|
||||
/// no address, no account, no time of asking. `expires_at` is when the token
|
||||
/// stops working, which the sweep needs, and it says no more than "somebody
|
||||
/// was introduced in the last ten minutes".
|
||||
final class IntroToken: Model, @unchecked Sendable {
|
||||
static let schema = "intro_tokens"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
/// Whose inbox the token opens.
|
||||
@Field(key: "user_id") var userID: Int
|
||||
/// Base64 of `SHA-256(token)`, as for delivery tokens.
|
||||
@Field(key: "token_hash") var tokenHash: String
|
||||
/// Unix seconds.
|
||||
@Field(key: "expires_at") var expiresAt: Int
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
/// A reusable "add me" link an account has published (design B, stage B2).
|
||||
///
|
||||
/// The relay holds `SHA-256(link secret)` and the account it leads to. No
|
||||
/// creation time and no record of who used it: the link is a capability, and
|
||||
/// how often it was spent and by whom is exactly the history it must not
|
||||
/// build up.
|
||||
final class ContactLink: Model, @unchecked Sendable {
|
||||
static let schema = "contact_links"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
@Field(key: "user_id") var userID: Int
|
||||
/// Base64 of `SHA-256(link secret)`.
|
||||
@Field(key: "link_hash") var linkHash: String
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
// MARK: - Migrations (wave 0b, 2026-10-07). All additive.
|
||||
|
||||
struct CreateIntroTokens: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(IntroToken.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("user_id", .int, .required)
|
||||
.field("token_hash", .string, .required)
|
||||
.field("expires_at", .int, .required)
|
||||
.unique(on: "token_hash")
|
||||
.create()
|
||||
if let sql = database as? any SQLDatabase {
|
||||
// The outstanding-intros cap counts by account.
|
||||
try await sql.raw(
|
||||
"CREATE INDEX IF NOT EXISTS intro_tokens_user ON intro_tokens (user_id)"
|
||||
).run()
|
||||
// The sweep deletes by expiry.
|
||||
try await sql.raw(
|
||||
"CREATE INDEX IF NOT EXISTS intro_tokens_expiry ON intro_tokens (expires_at)"
|
||||
).run()
|
||||
}
|
||||
try await Self.tuneAutovacuum(IntroToken.schema, on: database)
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(IntroToken.schema).delete()
|
||||
}
|
||||
|
||||
/// The settings every user-data table carries (`TuneUserDataAutovacuum`
|
||||
/// ran before these existed). Postgres only.
|
||||
static func tuneAutovacuum(_ table: String, on database: any Database) async throws {
|
||||
guard let pg = TuneUserDataAutovacuum.postgres(database) else { return }
|
||||
try await pg.raw(
|
||||
"""
|
||||
ALTER TABLE \(unsafeRaw: table) SET \
|
||||
(autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.0)
|
||||
"""
|
||||
).run()
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateContactLinks: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(ContactLink.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("user_id", .int, .required)
|
||||
.field("link_hash", .string, .required)
|
||||
.unique(on: "link_hash")
|
||||
.create()
|
||||
if let sql = database as? any SQLDatabase {
|
||||
try await sql.raw(
|
||||
"CREATE INDEX IF NOT EXISTS contact_links_user ON contact_links (user_id)"
|
||||
).run()
|
||||
}
|
||||
try await CreateIntroTokens.tuneAutovacuum(ContactLink.schema, on: database)
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(ContactLink.schema).delete()
|
||||
}
|
||||
}
|
||||
|
||||
/// `users.discoverable`, true unless the account chose Unlisted (design B,
|
||||
/// stage B1). Every existing account stays listed.
|
||||
///
|
||||
/// Deliberately NOT a field on the `User` model. Fluent names every model
|
||||
/// column in its SELECT, and two migrations early in the list
|
||||
/// (`AddUsernameSkeleton`, and `BackfillKeyLog` through `Device`) load rows
|
||||
/// through the model; a column added at the end of the list would be missing
|
||||
/// when they run on a fresh database, which is the trap
|
||||
/// `AddUserSuspendedAt` fell into. Read and written through `Discoverability`
|
||||
/// instead, so this migration can be appended like every other.
|
||||
struct AddUserDiscoverable: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(User.schema)
|
||||
.field("discoverable", .bool, .required, .sql(.default(true)))
|
||||
.update()
|
||||
}
|
||||
|
||||
/// Raw, because Fluent's SQLite driver turns `deleteField` into nothing
|
||||
/// at all (it predates SQLite's DROP COLUMN), and the revert then looks
|
||||
/// like it worked until the column is added again. Both dialects take
|
||||
/// this statement.
|
||||
func revert(on database: any Database) async throws {
|
||||
guard let sql = database as? any SQLDatabase else {
|
||||
try await database.schema(User.schema).deleteField("discoverable").update()
|
||||
return
|
||||
}
|
||||
try await sql.raw("ALTER TABLE users DROP COLUMN discoverable").run()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Listed and unlisted
|
||||
|
||||
/// Whether a username leads to an account, and the floor every "no" is held
|
||||
/// to.
|
||||
///
|
||||
/// An unlisted account must answer every door that turns a username into an
|
||||
/// account exactly as a name nobody holds: same status, same bytes, same
|
||||
/// rate-limit cost, and no faster or slower. The lookups differ underneath (a
|
||||
/// row found and filtered out against no row at all), so every "no such name"
|
||||
/// answer is held to a floor, measured the way `Routes.sealedRefusalFloorNanos`
|
||||
/// was.
|
||||
enum Discoverability {
|
||||
|
||||
/// The floor for an answer that says a name or an account is not there.
|
||||
/// The same 5 ms as the sealed refusal; see that constant for why a floor
|
||||
/// and not constant time.
|
||||
static let notFoundFloorNanos: UInt64 = 5_000_000
|
||||
|
||||
/// When an answer's clock started.
|
||||
static func clock() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
|
||||
|
||||
/// Wait out the remainder of the floor.
|
||||
static func holdFloor(since began: UInt64) async {
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds &- began
|
||||
if elapsed < notFoundFloorNanos {
|
||||
try? await Task.sleep(nanoseconds: notFoundFloorNanos - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The account holding `username`, only if it is listed. One query either
|
||||
/// way, so a miss and an unlisted name do the same work up to the row.
|
||||
static func listedUser(named username: String, on db: any Database) async throws -> User? {
|
||||
guard let sql = db as? any SQLDatabase else { return nil }
|
||||
struct Row: Decodable { let id: Int }
|
||||
guard
|
||||
let row = try await sql.raw(
|
||||
"""
|
||||
SELECT id FROM users
|
||||
WHERE username = \(bind: User.normalize(username)) AND discoverable = TRUE
|
||||
"""
|
||||
).first(decoding: Row.self)
|
||||
else { return nil }
|
||||
return try await User.find(row.id, on: db)
|
||||
}
|
||||
|
||||
/// Whether `userID` is an account that is listed. False for no account.
|
||||
static func isListed(_ userID: Int, on db: any Database) async throws -> Bool {
|
||||
guard let sql = db as? any SQLDatabase else { return false }
|
||||
struct Row: Decodable { let id: Int }
|
||||
return try await sql.raw(
|
||||
"SELECT id FROM users WHERE id = \(bind: userID) AND discoverable = TRUE"
|
||||
).first(decoding: Row.self) != nil
|
||||
}
|
||||
|
||||
/// Set it. Raw for the reason `AddUserDiscoverable` gives.
|
||||
static func set(_ userID: Int, listed: Bool, on db: any Database) async throws {
|
||||
guard let sql = db as? any SQLDatabase else { return }
|
||||
try await sql.raw(
|
||||
"UPDATE users SET discoverable = \(bind: listed) WHERE id = \(bind: userID)"
|
||||
).run()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handle certificates
|
||||
|
||||
/// A statement by this relay that `user_id` holds `handle` here, until `exp`
|
||||
/// (design C, decision DC4).
|
||||
///
|
||||
/// Signed with the key that signs the transparency log's head, whose public
|
||||
/// half is the `issuerPublicKey` both apps pin on first sight
|
||||
/// (`RelayIdentityPin`). The canonical bytes start `fnhandle-v1|` where the
|
||||
/// log head starts `fnkt-v1|`, so one can never be read as the other.
|
||||
///
|
||||
/// fnhandle-v1|<audience>|<user_id>|<handle>|<exp>
|
||||
///
|
||||
/// Handles are lowercase letters and digits only, so the separator cannot
|
||||
/// appear inside one.
|
||||
struct HandleCertificate: Content, Equatable {
|
||||
let v: Int
|
||||
let audience: String
|
||||
let user_id: Int
|
||||
let handle: String
|
||||
/// Unix seconds.
|
||||
let exp: Int
|
||||
/// Base64 Ed25519 signature over `canonical`.
|
||||
let sig: String
|
||||
|
||||
/// How long one is good for. Long enough to ride on a first message and
|
||||
/// be checked when it arrives; short enough that a closed account's
|
||||
/// certificates are not still about.
|
||||
static let lifetime = 7 * 86_400
|
||||
|
||||
static func canonical(audience: String, userID: Int, handle: String, exp: Int) -> Data {
|
||||
Data("fnhandle-v1|\(audience)|\(userID)|\(handle)|\(exp)".utf8)
|
||||
}
|
||||
|
||||
/// Nil when this relay has no signing key.
|
||||
static func issue(
|
||||
audience: String, userID: Int, handle: String, now: Int, signingKeyRaw: Data?
|
||||
) -> HandleCertificate? {
|
||||
guard let seed = signingKeyRaw,
|
||||
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: seed)
|
||||
else { return nil }
|
||||
let exp = now + lifetime
|
||||
let bytes = canonical(audience: audience, userID: userID, handle: handle, exp: exp)
|
||||
guard let signature = try? key.signature(for: bytes) else { return nil }
|
||||
return HandleCertificate(
|
||||
v: 1, audience: audience, user_id: userID, handle: handle, exp: exp,
|
||||
sig: signature.base64EncodedString())
|
||||
}
|
||||
|
||||
/// What a client does with one: check it against the pinned key.
|
||||
func verifies(with publicKeyRaw: Data, now: Int) -> Bool {
|
||||
guard v == 1, exp > now,
|
||||
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyRaw),
|
||||
let signature = Data(base64Encoded: sig)
|
||||
else { return false }
|
||||
return key.isValidSignature(
|
||||
signature,
|
||||
for: Self.canonical(audience: audience, userID: user_id, handle: handle, exp: exp))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Limiter keys that name nobody
|
||||
|
||||
/// Rate-limit keys for meters that are about an account.
|
||||
///
|
||||
/// Limiter keys live in Redis for an hour, and a key like `intro:to:114` is a
|
||||
/// record that somebody asked about account 114 in that hour. So a meter about
|
||||
/// an account is keyed by a keyed hash of it instead: still one bucket per
|
||||
/// account, and a dump of Redis names nobody without the relay's secret.
|
||||
///
|
||||
/// The secret is derived from the issuer's signing key when there is one (so
|
||||
/// every replica agrees), otherwise from the federation key, otherwise it is
|
||||
/// random per process, which only means per-replica counters on a relay
|
||||
/// configured with neither.
|
||||
enum LimiterKeys {
|
||||
private static let fallback = SymmetricKey(size: .bits256)
|
||||
|
||||
static func secret(_ config: Config) -> SymmetricKey {
|
||||
if let seed = config.issuerSigningKeyRaw ?? config.federationKeyRaw, !seed.isEmpty {
|
||||
return SymmetricKey(data: SHA256.hash(data: seed + Data("limiter-keys".utf8)))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// 16 hex characters standing for `parts`.
|
||||
static func subject(_ parts: [String], config: Config) -> String {
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: Data(parts.joined(separator: "|").utf8), using: secret(config))
|
||||
return Data(mac).prefix(8).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
extension Routes {
|
||||
/// See `LimiterKeys`.
|
||||
func limiterSubject(_ parts: String...) -> String {
|
||||
LimiterKeys.subject(parts, config: config)
|
||||
}
|
||||
}
|
||||
@@ -796,6 +796,9 @@ enum RelayMigrations {
|
||||
ForgetVouchProvenance(), ClearConversationFlags(),
|
||||
// Additive: new tables beside delivery_keys and roster_tags.
|
||||
CreatePerDeviceDigests(),
|
||||
// Wave 0b, additive: introductions, contact links, and the
|
||||
// Listed/Unlisted flag (every existing account stays listed).
|
||||
CreateIntroTokens(), CreateContactLinks(), AddUserDiscoverable(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,13 @@ struct Retention: @unchecked Sendable {
|
||||
blobs += 1
|
||||
}
|
||||
|
||||
// Introductions nobody spent in their ten minutes.
|
||||
let nowSeconds = Int(Date().timeIntervalSince1970)
|
||||
let intros = try await IntroToken.query(on: db)
|
||||
.filter(\.$expiresAt <= nowSeconds).count()
|
||||
try await IntroToken.query(on: db)
|
||||
.filter(\.$expiresAt <= nowSeconds).delete()
|
||||
|
||||
// Password-reset tokens.
|
||||
let resets = try await PasswordResetToken.query(on: db)
|
||||
.filter(\.$expiresAt < Date()).count()
|
||||
@@ -185,6 +192,7 @@ struct Retention: @unchecked Sendable {
|
||||
"undelivered": .string("\(undelivered)"),
|
||||
"attachments": .string("\(blobs)"),
|
||||
"resets": .string("\(resets)"),
|
||||
"intros": .string("\(intros)"),
|
||||
"devices": .string("\(expiredDevices)"),
|
||||
"push_orphans": .string("\(orphanedTokens)"),
|
||||
"sends_sealed": .string("\(sends.sealed)"),
|
||||
|
||||
@@ -357,9 +357,18 @@ struct Routes: Sendable {
|
||||
/// Build the bundle response for an account, popping a one-time prekey per
|
||||
/// device.
|
||||
func bundlesFor(userID target: Int, on request: Request) async throws -> Response {
|
||||
guard let bundles = try await bundleList(userID: target, on: request) else {
|
||||
throw WireError.notFound("No such user.")
|
||||
}
|
||||
return try .json(bundles)
|
||||
}
|
||||
|
||||
/// The same, as a value, or nil for an account with no devices. Shared
|
||||
/// with `POST /e2e/intro`, which pops prekeys exactly as this does.
|
||||
func bundleList(userID target: Int, on request: Request) async throws -> BundleResponse? {
|
||||
let devices = try await Device.query(on: request.db)
|
||||
.filter(\.$userID == target).all()
|
||||
guard !devices.isEmpty else { throw WireError.notFound("No such user.") }
|
||||
guard !devices.isEmpty else { return nil }
|
||||
|
||||
var out: [BundleResponse.Bundle] = []
|
||||
for d in devices {
|
||||
@@ -400,36 +409,55 @@ struct Routes: Sendable {
|
||||
out.append(bundle)
|
||||
}
|
||||
let targetFnid = try await User.find(target, on: request.db)?.fnid
|
||||
return try .json(BundleResponse(user_id: target, fnid: targetFnid, devices: out))
|
||||
return BundleResponse(user_id: target, fnid: targetFnid, devices: out)
|
||||
}
|
||||
|
||||
// MARK: - §8.3 GET /e2e/devices/{user_id}
|
||||
|
||||
///
|
||||
/// Registration and last-poll times only for the caller's own account
|
||||
/// (2026-10-07). They used to go to any member asking about any account,
|
||||
/// which made this an unmetered "when was this person last on their
|
||||
/// phone" oracle. Both apps read `created_at` on their own Devices screen
|
||||
/// through this route (iOS `ownDevices`, Android `Session.ownDevices`),
|
||||
/// and nowhere else, so their own account keeps both fields here until
|
||||
/// the fleet has moved to `GET /e2e/devices/me`.
|
||||
func listDevices(_ request: Request, target: Int, now: Int) async throws -> Response {
|
||||
_ = try await authenticate(
|
||||
let caller = try await authenticate(
|
||||
request, body: Data(), path: "/e2e/devices/\(target)", now: now)
|
||||
return try await devicesFor(userID: target, on: request)
|
||||
return try .json(
|
||||
try await deviceList(
|
||||
userID: target, includeTimes: target == caller.userID, on: request.db))
|
||||
}
|
||||
|
||||
/// Identity keys for an account.
|
||||
/// Identity keys for an account, and nothing about when its devices were
|
||||
/// registered or last seen. What every caller but the account itself gets,
|
||||
/// federated peers included.
|
||||
func devicesFor(userID target: Int, on request: Request) async throws -> Response {
|
||||
let devices = try await Device.query(on: request.db)
|
||||
try .json(try await deviceList(userID: target, includeTimes: false, on: request.db))
|
||||
}
|
||||
|
||||
/// `includeTimes` only for the account's own devices.
|
||||
func deviceList(
|
||||
userID target: Int, includeTimes: Bool, on db: any Database
|
||||
) async throws -> DeviceListResponse {
|
||||
let devices = try await Device.query(on: db)
|
||||
.filter(\.$userID == target).all()
|
||||
let iso = ISO8601DateFormatter()
|
||||
let targetFnid = try await User.find(target, on: request.db)?.fnid
|
||||
return try .json(
|
||||
DeviceListResponse(
|
||||
user_id: target,
|
||||
fnid: targetFnid,
|
||||
devices: devices.compactMap { d in
|
||||
d.id.map {
|
||||
.init(
|
||||
device_id: $0, ik_dh: d.ikDH, ik_sig: d.ikSig,
|
||||
proto: d.proto,
|
||||
created_at: d.createdAt.map(iso.string(from:)),
|
||||
last_seen: d.lastSeen.map(iso.string(from:)))
|
||||
}
|
||||
}))
|
||||
let targetFnid = try await User.find(target, on: db)?.fnid
|
||||
return DeviceListResponse(
|
||||
user_id: target,
|
||||
fnid: targetFnid,
|
||||
devices: devices.compactMap { d in
|
||||
d.id.map {
|
||||
.init(
|
||||
device_id: $0, ik_dh: d.ikDH, ik_sig: d.ikSig,
|
||||
proto: d.proto,
|
||||
// Absent, not null: the encoder leaves a nil out.
|
||||
created_at: includeTimes ? d.createdAt.map(iso.string(from:)) : nil,
|
||||
last_seen: includeTimes ? d.lastSeen.map(iso.string(from:)) : nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - §8.3a DELETE /e2e/devices/{device_id}
|
||||
@@ -562,25 +590,49 @@ struct Routes: Sendable {
|
||||
|
||||
// Metered before the capability is checked, so neither the digest
|
||||
// comparison nor the token lookup can be probed for free.
|
||||
//
|
||||
// Three buckets since 2026-10-07 (design C's denial of service).
|
||||
// The recipient-wide one counted every envelope, accepted or
|
||||
// not, so anybody holding one working delivery key could fill it
|
||||
// and nobody else could write to that person for the rest of the
|
||||
// hour. It now counts only REFUSED attempts, which is what it is
|
||||
// for (guessing), and is checked here and charged below. What one
|
||||
// sender can push at one recipient is bounded instead by the
|
||||
// per-(recipient, address) bucket. Keys name the recipient only
|
||||
// through `limiterSubject`.
|
||||
let n = input.messages.count
|
||||
let ok = await limits.consumeAll(
|
||||
[
|
||||
(key: "send:to:\(input.to)", amount: n, limit: config.sendRatePerHour),
|
||||
(key: "send:ip:\(clientIP)", amount: n, limit: config.sendRatePerHour),
|
||||
], now: now)
|
||||
let recipientKey = "send:to:\(limiterSubject("send-to", String(input.to)))"
|
||||
let pairKey = "send:toip:\(limiterSubject("send-toip", String(input.to), clientIP))"
|
||||
let addressKey = "send:ip:\(clientIP)"
|
||||
let open = await limits.peek(recipientKey, limit: config.sendRatePerHour, now: now)
|
||||
var ok = false
|
||||
if open {
|
||||
ok = await limits.consumeAll(
|
||||
[
|
||||
(key: addressKey, amount: n, limit: config.sendRatePerHour),
|
||||
(key: pairKey, amount: n, limit: config.sendRatePerRecipientIP),
|
||||
], now: now)
|
||||
}
|
||||
guard ok else {
|
||||
let wait = await limits.retryAfter(
|
||||
["send:to:\(input.to)", "send:ip:\(clientIP)"], now: now)
|
||||
[recipientKey, addressKey, pairKey], now: now)
|
||||
throw WireError.rateLimited(
|
||||
"Too many messages sent recently.",
|
||||
retryAfter: wait)
|
||||
}
|
||||
/// One refused attempt against the recipient-wide bucket.
|
||||
func refused() async -> WireError {
|
||||
_ = await limits.consume(recipientKey, limit: config.sendRatePerHour, now: now)
|
||||
return await Self.sealedRefusal(since: checkBegan)
|
||||
}
|
||||
|
||||
if let token {
|
||||
// Spent, not merely checked, and atomically, see
|
||||
// `spendDeliveryToken`.
|
||||
guard try await Self.spendDeliveryToken(token, for: input.to, on: request.db)
|
||||
else { throw await Self.sealedRefusal(since: checkBegan) }
|
||||
guard
|
||||
try await Self.spendDeliveryToken(
|
||||
token, for: input.to, now: now, on: request.db)
|
||||
else { throw await refused() }
|
||||
} else {
|
||||
// §14.4: the presented key must hash to the digest the
|
||||
// recipient published.
|
||||
@@ -625,7 +677,7 @@ struct Routes: Sendable {
|
||||
matched = matched || equal
|
||||
}
|
||||
guard matched, !published.isEmpty else {
|
||||
throw await Self.sealedRefusal(since: checkBegan)
|
||||
throw await refused()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -388,7 +388,10 @@ struct AccountRoutes: Sendable {
|
||||
let userID = try await verifyAppCredential(basic.password, on: request.db),
|
||||
let user = try await User.find(userID, on: request.db)
|
||||
else { throw WireError.credentialNotAccepted }
|
||||
return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username))
|
||||
return try .json(
|
||||
MeResponse(
|
||||
id: userID, name: user.displayName, slug: user.username,
|
||||
discoverable: try await Discoverability.isListed(userID, on: request.db)))
|
||||
}
|
||||
|
||||
// MARK: - GET /messenger/users?slug=<username>
|
||||
@@ -417,14 +420,22 @@ struct AccountRoutes: Sendable {
|
||||
throw WireError.rateLimited("Too many lookups. Try again shortly.")
|
||||
}
|
||||
|
||||
guard
|
||||
let user = try await User.query(on: request.db)
|
||||
.filter(\.$username == slug).first(),
|
||||
let id = user.id
|
||||
else {
|
||||
// An unlisted name is a miss (design B), except your own.
|
||||
let began = Discoverability.clock()
|
||||
let found: User?
|
||||
if let listed = try await Discoverability.listedUser(named: slug, on: request.db) {
|
||||
found = listed
|
||||
} else if let mine = try await User.find(callerID, on: request.db), mine.username == slug {
|
||||
found = mine
|
||||
} else {
|
||||
found = nil
|
||||
}
|
||||
guard let user = found, let id = user.id else {
|
||||
// A miss and a hit differ only in the array being empty, and both
|
||||
// cost a lookup against the throttle, so neither timing nor budget
|
||||
// distinguishes them.
|
||||
// cost a lookup against the throttle. A miss, listed or not, is
|
||||
// also held to the floor, so an unlisted name and an absent one
|
||||
// take the same time.
|
||||
await Discoverability.holdFloor(since: began)
|
||||
return try .json([MeResponse]())
|
||||
}
|
||||
return try .json([MeResponse(id: id, name: user.displayName, slug: user.username)])
|
||||
@@ -697,7 +708,7 @@ extension AccountRoutes {
|
||||
else { throw WireError.credentialNotAccepted }
|
||||
|
||||
let input = try request.content.decode(UpdateMeRequest.self)
|
||||
guard input.name != nil || input.password != nil else {
|
||||
guard input.name != nil || input.password != nil || input.discoverable != nil else {
|
||||
throw WireError.badRequest("Nothing to change.")
|
||||
}
|
||||
|
||||
@@ -728,12 +739,20 @@ extension AccountRoutes {
|
||||
}
|
||||
|
||||
try await user.update(on: request.db)
|
||||
// Listed or Unlisted (design B). Takes effect on the next lookup:
|
||||
// nothing is cached.
|
||||
if let listed = input.discoverable {
|
||||
try await Discoverability.set(userID, listed: listed, on: request.db)
|
||||
}
|
||||
|
||||
// Existing app credentials deliberately survive a password change, as
|
||||
// they once did: they are separate credentials, and revoking the
|
||||
// caller's own credential mid-request would sign them out of the device
|
||||
// they just used to change it.
|
||||
return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username))
|
||||
return try .json(
|
||||
MeResponse(
|
||||
id: userID, name: user.displayName, slug: user.username,
|
||||
discoverable: try await Discoverability.isListed(userID, on: request.db)))
|
||||
}
|
||||
|
||||
/// `DELETE /messenger/account`, remove the account and everything keyed to
|
||||
@@ -788,6 +807,12 @@ extension AccountRoutes {
|
||||
try await DeviceDeliveryKey.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await RosterTag.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await DeviceRosterTag.query(on: db).filter(\.$userID == userID).delete()
|
||||
// Capabilities that open this inbox: issued batches, introductions
|
||||
// and contact links. Delivery tokens were left behind before
|
||||
// 2026-10-07, keyed by an id nothing would ever hold again.
|
||||
try await DeliveryToken.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await IntroToken.query(on: db).filter(\.$userID == userID).delete()
|
||||
try await ContactLink.query(on: db).filter(\.$userID == userID).delete()
|
||||
|
||||
// Credentials last: while they exist the account is still reachable, so
|
||||
// they are what makes the deletion take effect.
|
||||
@@ -802,4 +827,6 @@ extension AccountRoutes {
|
||||
struct UpdateMeRequest: Content {
|
||||
let name: String?
|
||||
let password: String?
|
||||
/// Listed (true) or Unlisted (false), design B.
|
||||
var discoverable: Bool? = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Vapor
|
||||
|
||||
/// Asking without logging in (design D, stage D1).
|
||||
///
|
||||
/// Presence, the device directory and prekey bundles, for a request that
|
||||
/// carries no credential, with the target in the body rather than the URL.
|
||||
/// The authenticated routes put the asker's account beside the target in the
|
||||
/// same request, and the target in a URL that the proxy logs with the
|
||||
/// address; these put neither anywhere.
|
||||
///
|
||||
/// What is NOT gained, stated plainly: the relay still sees the address, which
|
||||
/// is usually the same one the asker's own authenticated poll came from a few
|
||||
/// seconds earlier. The gain is in what is stored (no targets in access logs,
|
||||
/// no meter keyed by an account id), not in what a running relay could watch.
|
||||
///
|
||||
/// The authenticated routes stay until the fleet has moved (DD4).
|
||||
extension Routes {
|
||||
|
||||
/// Tokens per anonymous presence request. Clients ask in batches of at
|
||||
/// most eight (DD3); this is a ceiling, not a target.
|
||||
static let maxAnonymousPresenceTokens = 64
|
||||
/// Presence tokens per address per hour.
|
||||
static let anonymousPresencePerHour = 20_000
|
||||
/// Directory reads per address per hour without a presence token, and
|
||||
/// per token with one.
|
||||
static let anonymousDirectoryPerHour = 600
|
||||
static let anonymousDirectoryPerCapability = 120
|
||||
|
||||
// MARK: - POST /e2e/anon/presence
|
||||
|
||||
func anonymousPresence(
|
||||
_ request: Request, body: Data, clientIP: String, now: Int
|
||||
) async throws -> Response {
|
||||
let input = try decodeBody(PresenceByTokenRequest.self, from: body)
|
||||
guard input.tokens.count <= Self.maxAnonymousPresenceTokens else {
|
||||
throw WireError.badRequest(
|
||||
"At most \(Self.maxAnonymousPresenceTokens) tokens per request.")
|
||||
}
|
||||
let key = "anon:presence:ip:\(clientIP)"
|
||||
guard
|
||||
await limits.consume(
|
||||
key, amount: max(1, input.tokens.count),
|
||||
limit: Self.anonymousPresencePerHour, now: now)
|
||||
else {
|
||||
throw WireError.rateLimited(
|
||||
"Too many presence requests from here recently.",
|
||||
retryAfter: await limits.retryAfter([key], now: now))
|
||||
}
|
||||
return try .json(
|
||||
try await MobileRoutes.presence(for: input.tokens, viewer: 0, on: request.db))
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/anon/devices
|
||||
|
||||
/// Identity keys for an account: no registration or last-seen times, to
|
||||
/// anybody.
|
||||
func anonymousDevices(
|
||||
_ request: Request, body: Data, clientIP: String, now: Int
|
||||
) async throws -> Response {
|
||||
let input = try decodeBody(AnonymousTargetRequest.self, from: body)
|
||||
let keys = try await anonymousMeterKeys(
|
||||
input, clientIP: clientIP, kind: "dir",
|
||||
withCapability: Self.anonymousDirectoryPerCapability,
|
||||
perAddress: [(Self.anonymousDirectoryPerHour, "ip")], on: request)
|
||||
try await consumeOrRefuse(keys, now: now)
|
||||
return try .json(
|
||||
try await deviceList(userID: input.user_id, includeTimes: false, on: request.db))
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/anon/bundles
|
||||
|
||||
/// Prekey bundles, popping a one-time prekey per device as
|
||||
/// `GET /e2e/keys/{id}` does, and metered so the pool cannot be drained:
|
||||
/// by the presence token when one is held, otherwise per address and per
|
||||
/// (address, account), the shape the authenticated route uses per caller.
|
||||
func anonymousBundles(
|
||||
_ request: Request, body: Data, clientIP: String, now: Int
|
||||
) async throws -> Response {
|
||||
let input = try decodeBody(AnonymousTargetRequest.self, from: body)
|
||||
let keys = try await anonymousMeterKeys(
|
||||
input, clientIP: clientIP, kind: "bundle",
|
||||
withCapability: config.bundleRatePerTarget,
|
||||
perAddress: [(config.bundleRatePerHour, "ip"), (config.bundleRatePerTarget, "ipto")],
|
||||
on: request)
|
||||
try await consumeOrRefuse(keys, now: now)
|
||||
return try await bundlesFor(userID: input.user_id, on: request)
|
||||
}
|
||||
|
||||
// MARK: - GET /e2e/devices/me
|
||||
|
||||
/// The caller's own devices, with when each registered and last polled.
|
||||
/// The one place those times are still served (see `listDevices`).
|
||||
func ownDevices(_ request: Request, now: Int) async throws -> Response {
|
||||
let caller = try await authenticate(
|
||||
request, body: Data(), path: "/e2e/devices/me", now: now)
|
||||
return try .json(
|
||||
try await deviceList(userID: caller.userID, includeTimes: true, on: request.db))
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
/// Which buckets an anonymous read is charged to.
|
||||
///
|
||||
/// With a presence token the subject issued (DD1), the read is the
|
||||
/// subject's contact asking, and the token is the bucket. Without one, or
|
||||
/// with one that is not this subject's, the address pays. A token that
|
||||
/// does not match is not refused, only not honoured, so this answers
|
||||
/// nothing about whether a token is good. No key holds an account id.
|
||||
private func anonymousMeterKeys(
|
||||
_ input: AnonymousTargetRequest, clientIP: String, kind: String,
|
||||
withCapability capLimit: Int,
|
||||
perAddress: [(limit: Int, scope: String)],
|
||||
on request: Request
|
||||
) async throws -> [(key: String, amount: Int, limit: Int)] {
|
||||
if let raw = input.cap, let token = MobileRoutes.validToken(raw),
|
||||
try await MobileRoutes.subject(ofToken: token, on: request.db) == input.user_id
|
||||
{
|
||||
let digest = Data(SHA256.hash(data: token)).base64EncodedString()
|
||||
return [
|
||||
(
|
||||
key: "anon:\(kind):cap:\(limiterSubject("anon-cap", digest))", amount: 1,
|
||||
limit: capLimit
|
||||
)
|
||||
]
|
||||
}
|
||||
return perAddress.map { entry in
|
||||
let key =
|
||||
entry.scope == "ip"
|
||||
? "anon:\(kind):ip:\(clientIP)"
|
||||
: "anon:\(kind):ipto:\(limiterSubject("anon-ipto", clientIP, String(input.user_id)))"
|
||||
return (key: key, amount: 1, limit: entry.limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeOrRefuse(
|
||||
_ keys: [(key: String, amount: Int, limit: Int)], now: Int
|
||||
) async throws {
|
||||
guard await limits.consumeAll(keys, now: now) else {
|
||||
throw WireError.rateLimited(
|
||||
"Too many requests recently. Try again shortly.",
|
||||
retryAfter: await limits.retryAfter(keys.map(\.key), now: now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnonymousTargetRequest: Decodable {
|
||||
let user_id: Int
|
||||
/// Optional: a presence token the subject issued, base64 32 bytes.
|
||||
let cap: String?
|
||||
}
|
||||
@@ -92,19 +92,34 @@ extension Routes {
|
||||
// MARK: - Spending one
|
||||
|
||||
/// Consume the token authorising one sealed submission to `user`.
|
||||
///
|
||||
/// Either one the recipient issued (`delivery_tokens`) or one the relay
|
||||
/// minted for an introduction (`intro_tokens`, still unexpired at `now`).
|
||||
/// Each is one statement, `DELETE ... RETURNING`, so two submissions
|
||||
/// racing on the same token cannot both see it and both be accepted: the
|
||||
/// row goes to whichever statement deletes it, and the other deletes
|
||||
/// nothing.
|
||||
static func spendDeliveryToken(
|
||||
_ token: Data, for user: Int, on db: any Database
|
||||
_ token: Data, for user: Int, now: Int, on db: any Database
|
||||
) async throws -> Bool {
|
||||
let hash = Data(SHA256.hash(data: token)).base64EncodedString()
|
||||
guard let sql = db as? any SQLDatabase else {
|
||||
// Fluent has no RETURNING, so a driver without SQLKit falls back to
|
||||
// the racy shape.
|
||||
let row = try await DeliveryToken.query(on: db)
|
||||
if let row = try await DeliveryToken.query(on: db)
|
||||
.filter(\.$userID == user)
|
||||
.filter(\.$tokenHash == hash)
|
||||
.first()
|
||||
guard let row else { return false }
|
||||
try await row.delete(on: db)
|
||||
{
|
||||
try await row.delete(on: db)
|
||||
return true
|
||||
}
|
||||
guard
|
||||
let intro = try await IntroToken.query(on: db)
|
||||
.filter(\.$userID == user).filter(\.$tokenHash == hash)
|
||||
.filter(\.$expiresAt > now).first()
|
||||
else { return false }
|
||||
try await intro.delete(on: db)
|
||||
return true
|
||||
}
|
||||
let rows = try await sql.raw(
|
||||
@@ -114,7 +129,18 @@ extension Routes {
|
||||
RETURNING id
|
||||
"""
|
||||
).all()
|
||||
return !rows.isEmpty
|
||||
if !rows.isEmpty { return true }
|
||||
// An expired introduction is not spent here and is not accepted; the
|
||||
// sweep removes it.
|
||||
let intros = try await sql.raw(
|
||||
"""
|
||||
DELETE FROM intro_tokens
|
||||
WHERE user_id = \(bind: user) AND token_hash = \(bind: hash)
|
||||
AND expires_at > \(bind: now)
|
||||
RETURNING id
|
||||
"""
|
||||
).all()
|
||||
return !intros.isEmpty
|
||||
}
|
||||
|
||||
/// How many unspent tokens an account holds.
|
||||
|
||||
@@ -97,13 +97,16 @@ extension Routes {
|
||||
}
|
||||
}
|
||||
|
||||
/// An unlisted handle answers as one nobody holds (design B), after the
|
||||
/// same `meterLookup` charge and no sooner than the same floor.
|
||||
func resolveIdentity(_ request: Request, handle: String) async throws -> Response {
|
||||
let normalized = User.normalize(handle)
|
||||
let began = Discoverability.clock()
|
||||
guard
|
||||
let user = try await User.query(on: request.db)
|
||||
.filter(\.$username == normalized).first(),
|
||||
let user = try await Discoverability.listedUser(named: normalized, on: request.db),
|
||||
let fnid = user.fnid
|
||||
else {
|
||||
await Discoverability.holdFloor(since: began)
|
||||
throw WireError.notFound("no such identity")
|
||||
}
|
||||
return try .json(
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// First contact without naming the sender (design C, stage C1), handle
|
||||
/// 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.
|
||||
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
|
||||
|
||||
/// Contact links one account may hold at once (DB3).
|
||||
static let maxContactLinks = 5
|
||||
|
||||
/// The one answer for "no such name", "not listed", "no such link" and
|
||||
/// "nothing to deliver to", held to `Discoverability.notFoundFloorNanos`.
|
||||
static let introNotFound = WireError.notFound("No such account.")
|
||||
|
||||
// MARK: - POST /e2e/intro
|
||||
|
||||
/// Introduce an anonymous caller to an account, by its handle (listed
|
||||
/// accounts only) or by one of its contact links.
|
||||
///
|
||||
/// Anonymous: no credential is read, nothing is stored about the caller,
|
||||
/// and the target is in the body rather than the URL so it reaches no
|
||||
/// access log. Metered per address, per network, and, once an account is
|
||||
/// found, per account and per (network, account), and capped on how many
|
||||
/// unspent introductions an account has waiting.
|
||||
///
|
||||
/// An unlisted handle, a handle nobody holds, an unknown or revoked link,
|
||||
/// and an account with no devices all get `introNotFound`, after the same
|
||||
/// address and network meters and no sooner than the floor.
|
||||
func introduce(
|
||||
_ request: Request, body: Data, clientIP: String, now: Int
|
||||
) async throws -> Response {
|
||||
let input = try decodeBody(IntroRequest.self, from: body)
|
||||
let handle = input.handle.map(User.normalize).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let linkHash: String?
|
||||
if let link = input.link {
|
||||
guard let raw = base64URLDecode(link) ?? Data(base64Encoded: link), raw.count == 32
|
||||
else { throw WireError.badRequest("link must be 32 bytes, base64.") }
|
||||
linkHash = Data(SHA256.hash(data: raw)).base64EncodedString()
|
||||
} else {
|
||||
linkHash = nil
|
||||
}
|
||||
guard (handle == nil) != (linkHash == nil) else {
|
||||
throw WireError.badRequest("Send exactly one of handle or link.")
|
||||
}
|
||||
|
||||
// Charged before anything is looked up, and the same charge for every
|
||||
// answer, so the budget cannot tell a listed name from the rest.
|
||||
let addressKey = "intro:ip:\(clientIP)"
|
||||
let network = AccountRoutes.network(of: clientIP)
|
||||
let networkKey = "intro:net:\(network)"
|
||||
guard
|
||||
await limits.consumeAll(
|
||||
[
|
||||
(key: addressKey, amount: 1, limit: config.introRatePerIP),
|
||||
(key: networkKey, amount: 1, limit: config.introRatePerNetwork),
|
||||
], now: now)
|
||||
else {
|
||||
throw WireError.rateLimited(
|
||||
"Too many introductions from here recently.",
|
||||
retryAfter: await limits.retryAfter([addressKey, networkKey], now: now))
|
||||
}
|
||||
|
||||
let began = Discoverability.clock()
|
||||
let target: User?
|
||||
if let handle {
|
||||
target = try await Discoverability.listedUser(named: handle, on: request.db)
|
||||
} else if let linkHash,
|
||||
let link = try await ContactLink.query(on: request.db)
|
||||
.filter(\.$linkHash == linkHash).first()
|
||||
{
|
||||
target = try await User.find(link.userID, on: request.db)
|
||||
} else {
|
||||
target = nil
|
||||
}
|
||||
guard let target, let targetID = target.id,
|
||||
try await Device.query(on: request.db).filter(\.$userID == targetID).count() > 0
|
||||
else {
|
||||
await Discoverability.holdFloor(since: began)
|
||||
throw Self.introNotFound
|
||||
}
|
||||
|
||||
// Past this point the caller has shown it can name the account, so
|
||||
// these may say it exists. Per (network, account) as well as per
|
||||
// account, so one network cannot spend an account's whole hour.
|
||||
let targetKey = "intro:to:\(limiterSubject("intro-to", String(targetID)))"
|
||||
let pairKey = "intro:tonet:\(limiterSubject("intro-tonet", String(targetID), network))"
|
||||
guard
|
||||
await limits.consumeAll(
|
||||
[
|
||||
(key: targetKey, amount: 1, limit: config.introRatePerTarget),
|
||||
(
|
||||
key: pairKey, amount: 1,
|
||||
limit: max(1, config.introRatePerTarget / 3)
|
||||
),
|
||||
], now: now)
|
||||
else {
|
||||
throw WireError.rateLimited(
|
||||
"This account has had too many introductions recently. Try again later.",
|
||||
retryAfter: await limits.retryAfter([targetKey, pairKey], now: now))
|
||||
}
|
||||
let waiting = try await IntroToken.query(on: request.db)
|
||||
.filter(\.$userID == targetID).filter(\.$expiresAt > now).count()
|
||||
guard waiting + Self.introTokensPerIntro <= Self.maxOutstandingIntroTokens 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Popped after the tokens exist: a failure here leaves tokens that
|
||||
// expire unused, never prekeys gone with nothing to show for them.
|
||||
guard let bundles = try await bundleList(userID: targetID, on: request) else {
|
||||
throw Self.introNotFound
|
||||
}
|
||||
return try .json(
|
||||
IntroResponse(
|
||||
user_id: targetID,
|
||||
fnid: target.fnid,
|
||||
intro_tokens: tokens.map { $0.base64EncodedString() },
|
||||
expires_in: Self.introLifetime,
|
||||
handle_cert: HandleCertificate.issue(
|
||||
audience: config.audience, userID: targetID, handle: target.username,
|
||||
now: now, signingKeyRaw: config.issuerSigningKeyRaw),
|
||||
devices: bundles.devices))
|
||||
}
|
||||
|
||||
// MARK: - GET /e2e/handle-cert
|
||||
|
||||
/// A certificate for the caller's own handle, to carry on a first message
|
||||
/// so the recipient can show a name without asking the relay who an id is.
|
||||
func ownHandleCertificate(_ request: Request, now: Int) async throws -> Response {
|
||||
let caller = try await authenticate(
|
||||
request, body: Data(), path: "/e2e/handle-cert", now: now)
|
||||
guard let user = try await User.find(caller.userID, on: request.db) else {
|
||||
throw WireError.notFound("No such account.")
|
||||
}
|
||||
guard
|
||||
let cert = HandleCertificate.issue(
|
||||
audience: config.audience, userID: caller.userID, handle: user.username,
|
||||
now: now, signingKeyRaw: config.issuerSigningKeyRaw)
|
||||
else {
|
||||
throw WireError(
|
||||
"handle_cert_unsigned",
|
||||
"This relay has no signing key, so it cannot certify handles.", 503)
|
||||
}
|
||||
return try .json(cert)
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/contact-links
|
||||
|
||||
/// Add and revoke this account's contact links, by hash.
|
||||
///
|
||||
/// Refuses rather than clamps at the limit: a link the relay quietly did
|
||||
/// not store is a link somebody shares and nobody can use.
|
||||
func setContactLinks(_ request: Request, body: Data, now: Int) async throws -> Response {
|
||||
let caller = try await authenticate(
|
||||
request, body: body, path: "/e2e/contact-links", now: now)
|
||||
let input = try decodeBody(ContactLinksRequest.self, from: body)
|
||||
func normalised(_ list: [String]) throws -> [String] {
|
||||
try list.map {
|
||||
guard let raw = Data(base64Encoded: $0), raw.count == 32 else {
|
||||
throw WireError.badRequest("each hash is a base64 32-byte SHA-256")
|
||||
}
|
||||
return raw.base64EncodedString()
|
||||
}
|
||||
}
|
||||
let adding = Array(Set(try normalised(input.add ?? [])))
|
||||
let dropping = try normalised(input.revoke ?? [])
|
||||
guard adding.count + dropping.count <= 2 * Self.maxContactLinks else {
|
||||
throw WireError.badRequest("Too many links in one request.")
|
||||
}
|
||||
|
||||
let key = "links:\(limiterSubject("links", String(caller.userID)))"
|
||||
guard await limits.consume(key, limit: Self.prekeysPerHour, now: now) else {
|
||||
throw WireError.rateLimited(
|
||||
"Too many link changes. Try again shortly.",
|
||||
retryAfter: await limits.retryAfter([key], now: now))
|
||||
}
|
||||
|
||||
let held: Int = try await request.db.retryingTransaction { db in
|
||||
if !dropping.isEmpty {
|
||||
try await ContactLink.query(on: db)
|
||||
.filter(\.$userID == caller.userID)
|
||||
.filter(\.$linkHash ~~ dropping)
|
||||
.delete()
|
||||
}
|
||||
let mine = Set(
|
||||
try await ContactLink.query(on: db)
|
||||
.filter(\.$userID == caller.userID).all().map(\.linkHash))
|
||||
let new = adding.filter { !mine.contains($0) }
|
||||
guard mine.count + new.count <= Self.maxContactLinks else {
|
||||
throw WireError.conflict(
|
||||
"An account can have at most \(Self.maxContactLinks) contact links. "
|
||||
+ "Revoke one first.")
|
||||
}
|
||||
// A hash another account already holds is refused, not shared:
|
||||
// two accounts behind one link would be a link to whichever the
|
||||
// lookup found first.
|
||||
if !new.isEmpty,
|
||||
try await ContactLink.query(on: db).filter(\.$linkHash ~~ new).count() > 0
|
||||
{
|
||||
throw WireError.conflict("That link is already in use. Make a new one.")
|
||||
}
|
||||
for hash in new {
|
||||
let row = ContactLink()
|
||||
row.userID = caller.userID
|
||||
row.linkHash = hash
|
||||
try await row.create(on: db)
|
||||
}
|
||||
return mine.count + new.count
|
||||
}
|
||||
return try .json(ContactLinksResponse(ok: true, links: held))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wire
|
||||
|
||||
struct IntroRequest: Decodable {
|
||||
/// A listed account's handle.
|
||||
let handle: String?
|
||||
/// A contact link's secret, base64 or base64url, 32 bytes.
|
||||
let link: String?
|
||||
}
|
||||
|
||||
struct IntroResponse: Content {
|
||||
let user_id: Int
|
||||
let fnid: String?
|
||||
/// Base64 32-byte tokens, each good for one sealed submission to
|
||||
/// `user_id`, presented as `delivery_token`.
|
||||
let intro_tokens: [String]
|
||||
let expires_in: Int
|
||||
/// Nil only on a relay with no signing key.
|
||||
let handle_cert: HandleCertificate?
|
||||
/// As from `GET /e2e/keys/{user_id}`, one-time prekeys popped.
|
||||
let devices: [BundleResponse.Bundle]
|
||||
}
|
||||
|
||||
struct ContactLinksRequest: Decodable {
|
||||
/// Base64 `SHA-256(link secret)` to add.
|
||||
let add: [String]?
|
||||
/// And to revoke.
|
||||
let revoke: [String]?
|
||||
}
|
||||
|
||||
struct ContactLinksResponse: Content {
|
||||
let ok: Bool
|
||||
/// How many links the account holds now.
|
||||
let links: Int
|
||||
}
|
||||
@@ -162,27 +162,26 @@ struct MobileRoutes: Sendable {
|
||||
func presenceByToken(_ request: Request) async throws -> Response {
|
||||
let me = try await caller(request)
|
||||
let input = try request.content.decode(PresenceByTokenRequest.self)
|
||||
return try .json(
|
||||
try await Self.presence(
|
||||
for: Array(input.tokens.prefix(Self.maxPresenceTokens)),
|
||||
viewer: me, on: request.db))
|
||||
}
|
||||
|
||||
let tokens = Array(input.tokens.prefix(Self.maxPresenceTokens))
|
||||
/// The answer for a set of tokens, whoever is asking.
|
||||
///
|
||||
/// Split from `caller()` (2026-10-07) so `POST /e2e/anon/presence` can
|
||||
/// give the same answer to a request that carries no credential at all.
|
||||
/// `viewer` is 0 there; nothing below depends on who is asking, which is
|
||||
/// the point of a token.
|
||||
static func presence(
|
||||
for tokens: [String], viewer: Int, on db: any Database
|
||||
) async throws -> PresenceByTokenResponse {
|
||||
var rows: [PresenceByTokenResponse.Row] = []
|
||||
for raw in tokens {
|
||||
guard let token = Self.validToken(raw) else {
|
||||
rows.append(.init(token: raw, presence: EffectiveStatus.offline.rawValue))
|
||||
continue
|
||||
}
|
||||
let digest = Data(SHA256.hash(data: token)).base64EncodedString()
|
||||
// A device's own set first, then the account-wide one an older
|
||||
// app publishes.
|
||||
let subject: Int?
|
||||
if let tag = try await DeviceRosterTag.query(on: request.db)
|
||||
.filter(\.$digest == digest).first()
|
||||
{
|
||||
subject = tag.userID
|
||||
} else {
|
||||
subject = try await RosterTag.query(on: request.db)
|
||||
.filter(\.$digest == digest).first()?.userID
|
||||
}
|
||||
guard let subject else {
|
||||
guard let token = Self.validToken(raw),
|
||||
let subject = try await subject(ofToken: token, on: db)
|
||||
else {
|
||||
rows.append(.init(token: raw, presence: EffectiveStatus.offline.rawValue))
|
||||
continue
|
||||
}
|
||||
@@ -193,18 +192,38 @@ struct MobileRoutes: Sendable {
|
||||
.init(
|
||||
token: raw,
|
||||
presence: try await Self.statusIgnoringRoster(
|
||||
of: subject, to: me, on: request.db
|
||||
of: subject, to: viewer, on: db
|
||||
).rawValue))
|
||||
}
|
||||
return try .json(PresenceByTokenResponse(presence: rows))
|
||||
return PresenceByTokenResponse(presence: rows)
|
||||
}
|
||||
|
||||
/// Whose presence a token shows, or nil. A device's own set first, then
|
||||
/// the account-wide one an older app publishes.
|
||||
static func subject(ofToken token: Data, on db: any Database) async throws -> Int? {
|
||||
let digest = Data(SHA256.hash(data: token)).base64EncodedString()
|
||||
if let tag = try await DeviceRosterTag.query(on: db)
|
||||
.filter(\.$digest == digest).first()
|
||||
{
|
||||
return tag.userID
|
||||
}
|
||||
return try await RosterTag.query(on: db)
|
||||
.filter(\.$digest == digest).first()?.userID
|
||||
}
|
||||
|
||||
// MARK: - GET /messenger/users/:id
|
||||
|
||||
/// Who a user id belongs to, username and display name, nothing more.
|
||||
///
|
||||
/// An unlisted account answers exactly as an id nobody holds, held to the
|
||||
/// same floor, except to itself.
|
||||
func userInfo(_ request: Request, userID: Int) async throws -> Response {
|
||||
_ = try await caller(request)
|
||||
guard let user = try await User.find(userID, on: request.db) else {
|
||||
let me = try await caller(request)
|
||||
let began = Discoverability.clock()
|
||||
let visible = userID == me ? true : try await Discoverability.isListed(userID, on: request.db)
|
||||
guard visible, let user = try await User.find(userID, on: request.db)
|
||||
else {
|
||||
await Discoverability.holdFloor(since: began)
|
||||
throw WireError.notFound("No member with that id.")
|
||||
}
|
||||
return try .json(
|
||||
@@ -223,10 +242,15 @@ struct MobileRoutes: Sendable {
|
||||
let input = try request.content.decode(MobileAddBuddyRequest.self)
|
||||
let username = User.normalize(input.username)
|
||||
|
||||
// An unlisted name answers as a name nobody holds (design B).
|
||||
let began = Discoverability.clock()
|
||||
guard
|
||||
let target = try await User.query(on: request.db)
|
||||
.filter(\.$username == username).first(), let targetID = target.id
|
||||
else { throw WireError.notFound("No member with that username.") }
|
||||
let target = try await Discoverability.listedUser(named: username, on: request.db),
|
||||
let targetID = target.id
|
||||
else {
|
||||
await Discoverability.holdFloor(since: began)
|
||||
throw WireError.notFound("No member with that username.")
|
||||
}
|
||||
guard targetID != me else {
|
||||
throw WireError.badRequest("You cannot add yourself.")
|
||||
}
|
||||
|
||||
@@ -642,6 +642,9 @@ struct MeResponse: Content {
|
||||
let id: Int
|
||||
let name: String
|
||||
let slug: String
|
||||
/// Only on the account's own record (`GET`/`PUT /messenger/me`), never in
|
||||
/// a lookup of somebody else. Absent when nil.
|
||||
var discoverable: Bool? = nil
|
||||
}
|
||||
|
||||
struct DeliveryKeyRequest: Decodable {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Design D, stage D1: presence, the directory and bundles without logging
|
||||
/// in, targets in the body, and no meter keyed by an account.
|
||||
@Suite("anonymous reads")
|
||||
struct AnonymousReadsTests {
|
||||
|
||||
private let now = W0B.now
|
||||
private let ip = "198.51.100.44"
|
||||
|
||||
private func post(
|
||||
_ h: RelayHarness, _ path: String, _ json: String,
|
||||
_ route: (Request, Data) async throws -> Response
|
||||
) async -> W0B.Answer {
|
||||
let body = Data(json.utf8)
|
||||
return await W0B.answer {
|
||||
try await route(h.anonymousRequest(.POST, path, body: body), body)
|
||||
}
|
||||
}
|
||||
|
||||
/// A subject with a device, a presence token issued to a contact, and a
|
||||
/// fresh heartbeat.
|
||||
private func subject(_ h: RelayHarness) async throws -> Data {
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007", prekeys: 3)
|
||||
let token = W0B.random32()
|
||||
let tag = DeviceRosterTag()
|
||||
tag.userID = 7
|
||||
tag.deviceID = "aaaa-0007"
|
||||
tag.digest = W0B.sha(token)
|
||||
try await tag.create(on: h.db)
|
||||
let u = try #require(try await User.find(7, on: h.db))
|
||||
u.lastSeenAt = Presence.coarsen(Date())
|
||||
try await u.update(on: h.db)
|
||||
return token
|
||||
}
|
||||
|
||||
@Test("anonymous presence gives the same answer as the signed-in route")
|
||||
func presence() async throws {
|
||||
try await withRelay { h in
|
||||
let token = try await subject(h)
|
||||
let viewer = try await W0B.account(h, id: 1, name: "viewer")
|
||||
let tokens = #"{"tokens":["\#(token.base64EncodedString())","\#(W0B.random32().base64EncodedString())"]}"#
|
||||
let anon = await post(h, "/e2e/anon/presence", tokens) { req, body in
|
||||
try await h.routes.anonymousPresence(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
let signedIn = await W0B.answer {
|
||||
try await W0B.mobile(h).presenceByToken(
|
||||
W0B.basic(h, .POST, "/messenger/presence", credential: viewer, body: tokens))
|
||||
}
|
||||
#expect(anon.status == 200)
|
||||
#expect(anon == signedIn)
|
||||
#expect(anon.body.contains("online") && anon.body.contains("offline"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("anonymous presence is capped per request and metered per address")
|
||||
func presenceLimits() async throws {
|
||||
try await withRelay { h in
|
||||
let many = (0...Routes.maxAnonymousPresenceTokens).map { _ in
|
||||
"\"\(W0B.random32().base64EncodedString())\""
|
||||
}
|
||||
let tooMany = await post(h, "/e2e/anon/presence", #"{"tokens":[\#(many.joined(separator: ","))]}"#) {
|
||||
req, body in try await h.routes.anonymousPresence(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(tooMany.status == 400)
|
||||
|
||||
// Malformed tokens cost the same against the meter and nothing
|
||||
// against the database, which keeps this test quick.
|
||||
let batch = (0..<Routes.maxAnonymousPresenceTokens).map { "\"t\($0)\"" }
|
||||
let json = #"{"tokens":[\#(batch.joined(separator: ","))]}"#
|
||||
let fits = Routes.anonymousPresencePerHour / Routes.maxAnonymousPresenceTokens
|
||||
for _ in 0..<fits {
|
||||
let ok = await post(h, "/e2e/anon/presence", json) { req, body in
|
||||
try await h.routes.anonymousPresence(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(ok.status == 200)
|
||||
}
|
||||
let over = await post(h, "/e2e/anon/presence", json) { req, body in
|
||||
try await h.routes.anonymousPresence(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(over.status == 429)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the anonymous directory has identity keys and no times")
|
||||
func directory() async throws {
|
||||
try await withRelay { h in
|
||||
_ = try await subject(h)
|
||||
let a = await post(h, "/e2e/anon/devices", #"{"user_id":7}"#) { req, body in
|
||||
try await h.routes.anonymousDevices(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(a.status == 200)
|
||||
#expect(a.body.contains("aaaa-0007") && a.body.contains("ik_sig"))
|
||||
#expect(!a.body.contains("created_at") && !a.body.contains("last_seen"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the signed-in directory gives times for your own devices only")
|
||||
func signedInDirectory() async throws {
|
||||
try await withRelay { h in
|
||||
_ = try await subject(h)
|
||||
try await W0B.account(h, id: 1, name: "viewer")
|
||||
let viewer = try await W0B.device(h, user: 1, id: "cccc-0001")
|
||||
let device = try #require(try await Device.find("cccc-0001", on: h.db))
|
||||
device.lastSeen = Date()
|
||||
try await device.save(on: h.db)
|
||||
|
||||
let other = try await h.routes.listDevices(
|
||||
h.request(.GET, "/e2e/devices/7", as: viewer, now: now), target: 7, now: now)
|
||||
let otherText = other.body.string ?? ""
|
||||
#expect(otherText.contains("aaaa-0007"))
|
||||
#expect(!otherText.contains("created_at") && !otherText.contains("last_seen"))
|
||||
|
||||
// Both apps read created_at from this route for their own Devices
|
||||
// screen, so it stays there until they move to /devices/me.
|
||||
let own = try await h.routes.listDevices(
|
||||
h.request(.GET, "/e2e/devices/1", as: viewer, now: now), target: 1, now: now)
|
||||
#expect((own.body.string ?? "").contains("created_at"))
|
||||
#expect((own.body.string ?? "").contains("last_seen"))
|
||||
|
||||
let me = try await h.routes.ownDevices(
|
||||
h.request(.GET, "/e2e/devices/me", as: viewer, now: now), now: now)
|
||||
let meText = me.body.string ?? ""
|
||||
#expect(meText.contains("cccc-0001") && meText.contains("created_at"))
|
||||
#expect(meText.contains("last_seen") && !meText.contains("aaaa-0007"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("federated peers get no times either")
|
||||
func federatedDirectory() async throws {
|
||||
try await withRelay { h in
|
||||
_ = try await subject(h)
|
||||
let r = try await h.routes.devicesFor(
|
||||
userID: 7, on: h.anonymousRequest(.GET, "/e2e/federation/devices/x"))
|
||||
#expect(!(r.body.string ?? "").contains("created_at"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("anonymous bundles pop a prekey, and are metered by token or by address")
|
||||
func bundles() async throws {
|
||||
let spy = W0B.SpyLimiter()
|
||||
try await withRelay(env: ["FN_BUNDLE_RATE_TARGET": "2"], limits: spy) { h in
|
||||
let token = try await subject(h)
|
||||
func fetch(_ json: String, from address: String = "198.51.100.44") async -> W0B.Answer {
|
||||
await post(h, "/e2e/anon/bundles", json) { req, body in
|
||||
try await h.routes.anonymousBundles(
|
||||
req, body: body, clientIP: address, now: now)
|
||||
}
|
||||
}
|
||||
let first = await fetch(#"{"user_id":7}"#)
|
||||
#expect(first.status == 200 && first.body.contains("opk-1"))
|
||||
#expect(try await Routes.poolSize(PreKey.self, "aaaa-0007", on: h.db) == 2)
|
||||
|
||||
// Per (address, account): the second fills it, the third is refused.
|
||||
_ = await fetch(#"{"user_id":7}"#)
|
||||
let third = await fetch(#"{"user_id":7}"#)
|
||||
#expect(third.status == 429)
|
||||
|
||||
// With the subject's presence token the token is the bucket, so
|
||||
// the same address is not refused.
|
||||
let capped = await fetch(
|
||||
#"{"user_id":7,"cap":"\#(token.base64EncodedString())"}"#)
|
||||
#expect(capped.status == 200)
|
||||
// A token that is not this subject's is ignored, not refused, and
|
||||
// the address pays as before.
|
||||
let wrongCap = await fetch(
|
||||
#"{"user_id":7,"cap":"\#(W0B.random32().base64EncodedString())"}"#)
|
||||
#expect(wrongCap.status == 429)
|
||||
|
||||
// No meter key names the account.
|
||||
for key in spy.keys where key.hasPrefix("anon:") {
|
||||
#expect(!key.split(separator: ":").contains("7"), "\(key) names the account")
|
||||
}
|
||||
#expect(spy.keys.contains { $0.hasPrefix("anon:bundle:cap:") })
|
||||
#expect(spy.keys.contains { $0.hasPrefix("anon:bundle:ipto:") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the anonymous directory without a token is metered per address")
|
||||
func directoryMeter() async throws {
|
||||
try await withRelay { h in
|
||||
_ = try await subject(h)
|
||||
for _ in 0..<Routes.anonymousDirectoryPerHour {
|
||||
_ = await post(h, "/e2e/anon/devices", #"{"user_id":7}"#) { req, body in
|
||||
try await h.routes.anonymousDevices(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
}
|
||||
let over = await post(h, "/e2e/anon/devices", #"{"user_id":7}"#) { req, body in
|
||||
try await h.routes.anonymousDevices(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
#expect(over.status == 429)
|
||||
let elsewhere = await post(h, "/e2e/anon/devices", #"{"user_id":7}"#) { req, body in
|
||||
try await h.routes.anonymousDevices(
|
||||
req, body: body, clientIP: "203.0.113.1", now: now)
|
||||
}
|
||||
#expect(elsewhere.status == 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Design C's denial of service on the sealed path.
|
||||
@Suite("sealed send meters")
|
||||
struct SealedSendMeterTests {
|
||||
|
||||
private let now = W0B.now
|
||||
|
||||
private func keyHolder(_ h: RelayHarness) async throws -> String {
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let key = W0B.random32()
|
||||
let row = DeviceDeliveryKey()
|
||||
row.userID = 7
|
||||
row.deviceID = "aaaa-0007"
|
||||
row.digest = W0B.sha(key)
|
||||
try await row.create(on: h.db)
|
||||
return key.base64EncodedString()
|
||||
}
|
||||
|
||||
private func send(
|
||||
_ h: RelayHarness, key: String, from address: String
|
||||
) async -> W0B.Answer {
|
||||
let body = Data(
|
||||
#"{"to":7,"messages":[{"to_device":"aaaa-0007","payload":"ct"}],"delivery_key":"\#(key)"}"#
|
||||
.utf8)
|
||||
return await W0B.answer {
|
||||
try await h.routes.send(
|
||||
h.anonymousRequest(.POST, "/e2e/messages", body: body), body: body,
|
||||
clientIP: address, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("accepted sealed sends do not use up the recipient's bucket")
|
||||
func acceptedDoNotLockOthersOut() async throws {
|
||||
try await withRelay(env: ["FN_SEND_RATE": "5", "FN_SEND_RATE_RECIPIENT_IP": "5"]) { h in
|
||||
let key = try await keyHolder(h)
|
||||
// One sender, a whole hour's worth.
|
||||
for _ in 0..<5 {
|
||||
#expect(await send(h, key: key, from: "198.51.100.1").status == 200)
|
||||
}
|
||||
// That sender is now held by the per-(recipient, address) bucket...
|
||||
#expect(await send(h, key: key, from: "198.51.100.1").status == 429)
|
||||
// ...and everybody else can still write.
|
||||
#expect(await send(h, key: key, from: "203.0.113.2").status == 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("refused sealed attempts are what fill the recipient's bucket")
|
||||
func refusalsCount() async throws {
|
||||
try await withRelay(env: ["FN_SEND_RATE": "3", "FN_SEND_RATE_RECIPIENT_IP": "100"]) { h in
|
||||
let key = try await keyHolder(h)
|
||||
let wrong = W0B.random32().base64EncodedString()
|
||||
for n in 1...3 {
|
||||
#expect(await send(h, key: wrong, from: "198.51.100.\(n)").status == 401)
|
||||
}
|
||||
// Guessing has used the recipient's hour: nothing further is
|
||||
// checked, right key or not.
|
||||
#expect(await send(h, key: wrong, from: "203.0.113.9").status == 429)
|
||||
#expect(await send(h, key: key, from: "203.0.113.9").status == 429)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("one address cannot push past its share at one recipient")
|
||||
func perRecipientAddress() async throws {
|
||||
try await withRelay(env: ["FN_SEND_RATE": "100", "FN_SEND_RATE_RECIPIENT_IP": "2"]) { h in
|
||||
let key = try await keyHolder(h)
|
||||
#expect(await send(h, key: key, from: "198.51.100.1").status == 200)
|
||||
#expect(await send(h, key: key, from: "198.51.100.1").status == 200)
|
||||
#expect(await send(h, key: key, from: "198.51.100.1").status == 429)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Design C, stage C1: `POST /e2e/intro`, its tokens, its meters, and handle
|
||||
/// certificates.
|
||||
@Suite("introductions")
|
||||
struct IntroductionTests {
|
||||
|
||||
private let now = W0B.now
|
||||
|
||||
@Test("an introduction by handle returns tokens, bundles and a certificate")
|
||||
func byHandle() async throws {
|
||||
try await withRelay(signing: true) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007", prekeys: 2)
|
||||
let r = try await W0B.intro(h, handle: "Ada")
|
||||
#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)
|
||||
#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.
|
||||
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.allSatisfy { $0.userID == 7 && $0.expiresAt == now + 600 })
|
||||
|
||||
let cert = try #require(body.handle_cert)
|
||||
#expect(cert.handle == "ada" && cert.user_id == 7 && cert.audience == "test.relay")
|
||||
#expect(cert.verifies(with: h.issuerKey.publicKey.rawRepresentation, now: now))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an intro token opens exactly one sealed send, and never names a sender")
|
||||
func singleUse() async throws {
|
||||
try await withRelay { h in
|
||||
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 first = try await W0B.sealedSend(h, to: 7, devices: ["aaaa-0007"], token: token)
|
||||
#expect(first.status == .ok)
|
||||
let again = await W0B.answer {
|
||||
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)
|
||||
|
||||
let stored = try await Envelope.query(on: h.db).all()
|
||||
#expect(stored.count == 1)
|
||||
#expect(stored.first?.fromUser == 0 && stored.first?.fromDevice == "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an intro token for one account does not open another's inbox")
|
||||
func boundToTarget() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
try await W0B.device(h, user: 8, id: "bbbb-0008")
|
||||
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])
|
||||
}
|
||||
#expect(wrong.status == 401)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an intro token past its ten minutes is refused, and the sweep removes it")
|
||||
func expiry() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
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(
|
||||
IntroResponse.self, try await W0B.intro(h, handle: "ada", 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)
|
||||
}
|
||||
#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)
|
||||
|
||||
let inTime = try await W0B.sealedSend(
|
||||
h, to: 7, devices: ["aaaa-0007"], token: body.intro_tokens[1],
|
||||
now: minted + 599)
|
||||
#expect(inTime.status == .ok)
|
||||
|
||||
try await Retention(
|
||||
db: h.db, config: h.routes.config, logger: h.app.logger,
|
||||
attachmentStore: h.routes.attachmentStore,
|
||||
sendMetrics: SendMetrics(), redis: nil
|
||||
).sweepOnce()
|
||||
#expect(try await IntroToken.query(on: h.db).count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a fresh intro token survives the sweep")
|
||||
func sweepKeepsLive() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
_ = try await W0B.intro(h, handle: "ada", now: Int(Date().timeIntervalSince1970))
|
||||
try await Retention(
|
||||
db: h.db, config: h.routes.config, logger: h.app.logger,
|
||||
attachmentStore: h.routes.attachmentStore,
|
||||
sendMetrics: SendMetrics(), redis: nil
|
||||
).sweepOnce()
|
||||
#expect(try await IntroToken.query(on: h.db).count() == 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meters
|
||||
|
||||
@Test("introductions are metered per address")
|
||||
func perAddress() async throws {
|
||||
try await withRelay(env: ["FN_INTRO_RATE": "2"]) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
for _ in 0..<2 { _ = try await W0B.intro(h, handle: "ada", ip: "198.51.100.7") }
|
||||
let third = await W0B.answer { try await W0B.intro(h, handle: "ada", ip: "198.51.100.7") }
|
||||
#expect(third.status == 429)
|
||||
// Another network is not affected.
|
||||
let elsewhere = await W0B.answer {
|
||||
try await W0B.intro(h, handle: "ada", ip: "203.0.113.5")
|
||||
}
|
||||
#expect(elsewhere.status == 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("introductions are metered per network, the sign-up buckets")
|
||||
func perNetwork() async throws {
|
||||
try await withRelay(env: ["FN_INTRO_RATE_NETWORK": "3"]) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
for n in 1...3 { _ = try await W0B.intro(h, handle: "ada", ip: "198.51.100.\(n)") }
|
||||
let fourth = await W0B.answer {
|
||||
try await W0B.intro(h, handle: "ada", ip: "198.51.100.99")
|
||||
}
|
||||
#expect(fourth.status == 429, "a /24 was not one bucket")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("introductions are metered per account, and one network cannot spend it all")
|
||||
func perTarget() async throws {
|
||||
try await withRelay(env: ["FN_INTRO_RATE_TARGET": "6"]) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
try await W0B.device(h, user: 8, id: "bbbb-0008")
|
||||
// A third of the account's hour per network.
|
||||
for n in 1...2 { _ = try await W0B.intro(h, handle: "ada", ip: "198.51.100.\(n)") }
|
||||
let sameNetwork = await W0B.answer {
|
||||
try await W0B.intro(h, handle: "ada", ip: "198.51.100.3")
|
||||
}
|
||||
#expect(sameNetwork.status == 429)
|
||||
for n in 1...4 { _ = try await W0B.intro(h, handle: "ada", ip: "203.0.\(n).1") }
|
||||
let seventh = await W0B.answer { try await W0B.intro(h, handle: "ada", ip: "192.0.2.1") }
|
||||
#expect(seventh.status == 429, "the per-account hour was not enforced")
|
||||
// Somebody else's hour is untouched.
|
||||
let other = await W0B.answer { try await W0B.intro(h, handle: "bea", ip: "192.0.2.1") }
|
||||
#expect(other.status == 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an account's unspent introductions are capped")
|
||||
func outstandingCap() async throws {
|
||||
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
|
||||
for n in 0..<fits {
|
||||
let r = try await W0B.intro(h, handle: "ada", ip: "10.\(n).0.1")
|
||||
#expect(r.status == .ok)
|
||||
}
|
||||
let over = await W0B.answer { try await W0B.intro(h, handle: "ada", ip: "10.250.0.1") }
|
||||
#expect(over.status == 429, "the outstanding cap was not enforced")
|
||||
// Expired ones do not count against it.
|
||||
let later = await W0B.answer {
|
||||
try await W0B.intro(h, handle: "ada", ip: "10.251.0.1", now: now + 601)
|
||||
}
|
||||
#expect(later.status == 200)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Doors
|
||||
|
||||
@Test("an unlisted handle and a missing one get the same answer, the same charge and the floor")
|
||||
func unlistedIsMissing() async throws {
|
||||
let spy = W0B.SpyLimiter()
|
||||
try await withRelay(limits: spy) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada", listed: false)
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
// Listed, but with nothing to deliver to.
|
||||
try await W0B.account(h, id: 9, name: "cat")
|
||||
try await W0B.device(h, user: 8, id: "bbbb-0008")
|
||||
|
||||
func timed(_ handle: String) async -> (W0B.Answer, UInt64, [String]) {
|
||||
let before = spy.keys.count
|
||||
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...]))
|
||||
}
|
||||
let (unlisted, t1, k1) = await timed("ada")
|
||||
let (missing, t2, k2) = await timed("nobody")
|
||||
let (noDevices, t3, k3) = await timed("cat")
|
||||
#expect(unlisted.status == 404)
|
||||
#expect(unlisted == missing, "\(unlisted) vs \(missing)")
|
||||
#expect(noDevices == missing)
|
||||
#expect(k1 == k2 && k2 == k3, "the meters differed: \(k1) \(k2) \(k3)")
|
||||
for t in [t1, t2, t3] { #expect(t >= Discoverability.notFoundFloorNanos) }
|
||||
#expect(try await IntroToken.query(on: h.db).count() == 0)
|
||||
|
||||
let listed = await W0B.answer { try await W0B.intro(h, handle: "bea") }
|
||||
#expect(listed.status == 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a contact link introduces an unlisted account; a revoked link is a missing one")
|
||||
func byLink() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada", listed: false)
|
||||
let d = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let secret = W0B.random32()
|
||||
let added = try await ContactLinkTests.links(h, as: d, add: [W0B.sha(secret)])
|
||||
#expect(added.status == .ok)
|
||||
|
||||
let link = base64URLEncode(secret)
|
||||
let r = try await W0B.intro(h, link: link)
|
||||
#expect(r.status == .ok)
|
||||
#expect(try W0B.decode(IntroResponse.self, r).user_id == 7)
|
||||
// Standard base64 is taken too.
|
||||
let std = try await W0B.intro(h, link: secret.base64EncodedString())
|
||||
#expect(std.status == .ok)
|
||||
|
||||
_ = try await ContactLinkTests.links(h, as: d, revoke: [W0B.sha(secret)])
|
||||
let revoked = await W0B.answer { try await W0B.intro(h, link: link) }
|
||||
let unknown = await W0B.answer {
|
||||
try await W0B.intro(h, link: base64URLEncode(W0B.random32()))
|
||||
}
|
||||
let missing = await W0B.answer { try await W0B.intro(h, handle: "nobody") }
|
||||
#expect(revoked.status == 404)
|
||||
#expect(revoked == unknown && unknown == missing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("exactly one of handle or link")
|
||||
func shape() async throws {
|
||||
try await withRelay { h in
|
||||
let neither = await W0B.answer { try await W0B.intro(h) }
|
||||
let both = await W0B.answer {
|
||||
try await W0B.intro(h, handle: "ada", link: base64URLEncode(W0B.random32()))
|
||||
}
|
||||
let short = await W0B.answer { try await W0B.intro(h, link: "AAAA") }
|
||||
#expect(neither.status == 400 && both.status == 400 && short.status == 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The race
|
||||
|
||||
/// Two relays on one database file, both spending the same tokens at the
|
||||
/// same moment. A check-then-delete would let both through; one
|
||||
/// `DELETE ... RETURNING` cannot.
|
||||
@Test("two relays spending one intro token: exactly one is accepted")
|
||||
func race() async throws {
|
||||
try await withRelayPair { a, b in
|
||||
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 {
|
||||
let r = try await W0B.intro(a, handle: "ada", ip: "10.\(n).0.1")
|
||||
tokens += try W0B.decode(IntroResponse.self, r).intro_tokens
|
||||
}
|
||||
let accepted = try await withThrowingTaskGroup(of: Int.self) { group in
|
||||
for token in tokens {
|
||||
for relay in [a, b, a, b] {
|
||||
group.addTask {
|
||||
let raw = Data(base64Encoded: token)!
|
||||
return try await Routes.spendDeliveryToken(
|
||||
raw, for: 7, now: W0B.now, on: relay.db) ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
var total = 0
|
||||
for try await n in group { total += n }
|
||||
return total
|
||||
}
|
||||
#expect(accepted == tokens.count, "\(accepted) spends for \(tokens.count) tokens")
|
||||
#expect(try await IntroToken.query(on: a.db).count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nothing about the asker
|
||||
|
||||
@Test("no row anywhere records who asked for an introduction")
|
||||
func noRowRecordsASender() async throws {
|
||||
try await withRelay(signing: true) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let asker = "198.51.100.231"
|
||||
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)
|
||||
|
||||
let sql = try #require(h.db as? any SQLDatabase)
|
||||
struct Column: Decodable { let name: String }
|
||||
let columns = try await sql.raw("PRAGMA table_info(intro_tokens)")
|
||||
.all(decoding: Column.self).map(\.name)
|
||||
#expect(Set(columns) == ["id", "user_id", "token_hash", "expires_at"])
|
||||
|
||||
// Every table, every row, rendered as text: the address appears
|
||||
// nowhere.
|
||||
struct Table: Decodable { let name: String }
|
||||
let tables = try await sql.raw(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).all(decoding: Table.self).map(\.name)
|
||||
#expect(tables.contains("intro_tokens"))
|
||||
for table in tables {
|
||||
let rows = try await sql.raw(
|
||||
SQLQueryString("SELECT * FROM \(unsafeRaw: table)")
|
||||
).all()
|
||||
for row in rows {
|
||||
for column in row.allColumns {
|
||||
let text = (try? row.decode(column: column, as: String.self)) ?? ""
|
||||
#expect(!text.contains(asker), "\(table).\(column) holds the asker")
|
||||
}
|
||||
}
|
||||
}
|
||||
let stored = try #require(try await Envelope.query(on: h.db).first())
|
||||
#expect(stored.fromUser == 0 && stored.fromDevice == "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Certificates
|
||||
|
||||
@Test("the caller's own handle certificate verifies against the pinned key, and only as issued")
|
||||
func ownCertificate() async throws {
|
||||
try await withRelay(signing: true) { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
let d = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let req = h.request(.GET, "/e2e/handle-cert", as: d, now: now)
|
||||
let cert = try W0B.decode(
|
||||
HandleCertificate.self, try await h.routes.ownHandleCertificate(req, now: now))
|
||||
let pinned = h.issuerKey.publicKey.rawRepresentation
|
||||
#expect(cert.verifies(with: pinned, now: now))
|
||||
#expect(cert.exp == now + HandleCertificate.lifetime)
|
||||
|
||||
// The exact bytes signed.
|
||||
let sig = try #require(Data(base64Encoded: cert.sig))
|
||||
let canonical = Data("fnhandle-v1|test.relay|7|ada|\(now + HandleCertificate.lifetime)".utf8)
|
||||
#expect(h.issuerKey.publicKey.isValidSignature(sig, for: canonical))
|
||||
|
||||
// Any field changed, it does not verify.
|
||||
let other = HandleCertificate(
|
||||
v: 1, audience: cert.audience, user_id: 8, handle: cert.handle,
|
||||
exp: cert.exp, sig: cert.sig)
|
||||
let renamed = HandleCertificate(
|
||||
v: 1, audience: cert.audience, user_id: 7, handle: "eve",
|
||||
exp: cert.exp, sig: cert.sig)
|
||||
#expect(!other.verifies(with: pinned, now: now))
|
||||
#expect(!renamed.verifies(with: pinned, now: now))
|
||||
#expect(!cert.verifies(with: pinned, now: cert.exp), "an expired one verified")
|
||||
// And it is not a log head: the same key, a different prefix.
|
||||
#expect(
|
||||
!h.issuerKey.publicKey.isValidSignature(
|
||||
sig, for: Data("fnkt-v1|7|ada".utf8)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a relay without a signing key says so rather than issuing nothing")
|
||||
func unsigned() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
let d = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let req = h.request(.GET, "/e2e/handle-cert", as: d, now: now)
|
||||
let a = await W0B.answer { try await h.routes.ownHandleCertificate(req, now: now) }
|
||||
#expect(a.status == 503)
|
||||
let intro = try W0B.decode(IntroResponse.self, try await W0B.intro(h, handle: "ada"))
|
||||
#expect(intro.handle_cert == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Response floors on the username doors, measured the way the sealed
|
||||
/// refusal's 5 ms floor was: in process, arms interleaved, many samples each.
|
||||
///
|
||||
/// By default a handful of samples, asserting only what is deterministic:
|
||||
/// every "not there" answer took at least the floor. With
|
||||
/// `FN_TIMING_SAMPLES=500` it also prints, per door and arm, the median and
|
||||
/// spread, and the same for the unfloored lookup underneath, which is the
|
||||
/// separation the floor exists to hide.
|
||||
@Suite("username door timing", .serialized)
|
||||
struct NameDoorTimingTests {
|
||||
|
||||
private let now = W0B.now
|
||||
|
||||
private var samples: Int {
|
||||
Int(ProcessInfo.processInfo.environment["FN_TIMING_SAMPLES"] ?? "") ?? 8
|
||||
}
|
||||
|
||||
/// Admits everything, so the meters are not what is being timed.
|
||||
private final class Open: RateLimiting, @unchecked Sendable {
|
||||
func consume(_ key: String, amount: Int, limit: Int, now: Int) async -> Bool { true }
|
||||
func consumeAll(_ r: [(key: String, amount: Int, limit: Int)], now: Int) async -> Bool {
|
||||
true
|
||||
}
|
||||
func peek(_ key: String, limit: Int, now: Int) async -> Bool { true }
|
||||
func clear(_ key: String) async {}
|
||||
func retryAfter(_ keys: [String], now: Int) async -> Int { 0 }
|
||||
}
|
||||
|
||||
struct Stats: CustomStringConvertible {
|
||||
let values: [Double] // microseconds
|
||||
init(_ ns: [UInt64]) { values = ns.map { Double($0) / 1000 }.sorted() }
|
||||
func q(_ p: Double) -> Double { values[min(values.count - 1, Int(Double(values.count) * p))] }
|
||||
var median: Double { q(0.5) }
|
||||
var description: String {
|
||||
String(format: "p10 %.0f median %.0f p90 %.0f max %.0f us", q(0.1), median, q(0.9), values.last ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func clock(_ body: () async -> Void) async -> UInt64 {
|
||||
let start = DispatchTime.now().uptimeNanoseconds
|
||||
await body()
|
||||
return DispatchTime.now().uptimeNanoseconds - start
|
||||
}
|
||||
|
||||
@Test("every not-there answer on every door is held to the floor")
|
||||
func floors() async throws {
|
||||
try await withRelay(limits: Open()) { h in
|
||||
let cred = try await W0B.account(h, id: 1, name: "asker")
|
||||
try await W0B.account(h, id: 7, name: "hidden", listed: false)
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007", prekeys: 0)
|
||||
let u = try #require(try await User.find(7, on: h.db))
|
||||
u.fnid = "fn-7"
|
||||
try await u.update(on: h.db)
|
||||
// Some bulk, so the index has something to do.
|
||||
for n in 0..<200 {
|
||||
let x = User()
|
||||
x.username = "filler\(n)"
|
||||
x.email = "filler\(n)@example.com"
|
||||
x.passwordHash = "x"
|
||||
x.displayName = "Filler"
|
||||
try await x.create(on: h.db)
|
||||
}
|
||||
let a = W0B.accounts(h)
|
||||
let m = W0B.mobile(h)
|
||||
|
||||
let doors: [(String, @Sendable (String, Int) async -> Void)] = [
|
||||
("lookup ?slug=", { name, _ in
|
||||
_ = try? await a.lookup(
|
||||
W0B.basic(h, .GET, "/messenger/users?slug=\(name)", credential: cred),
|
||||
now: W0B.now)
|
||||
}),
|
||||
("POST /messenger/buddies", { name, _ in
|
||||
_ = try? await m.addBuddy(
|
||||
W0B.basic(
|
||||
h, .POST, "/messenger/buddies", credential: cred,
|
||||
body: #"{"username":"\#(name)"}"#))
|
||||
}),
|
||||
("GET /e2e/account/identity/:handle", { name, _ in
|
||||
_ = try? await h.routes.resolveIdentity(
|
||||
h.anonymousRequest(.GET, "/e2e/account/identity/x"), handle: name)
|
||||
}),
|
||||
("GET /messenger/users/:id", { name, _ in
|
||||
let id = name == "hidden" ? 7 : 999_999
|
||||
_ = try? await m.userInfo(
|
||||
W0B.basic(h, .GET, "/messenger/users/\(id)", credential: cred),
|
||||
userID: id)
|
||||
}),
|
||||
("POST /e2e/intro {handle}", { name, i in
|
||||
_ = try? await W0B.intro(h, handle: name, ip: "10.\(i % 250).\(i / 250).1")
|
||||
}),
|
||||
]
|
||||
|
||||
var report: [String] = ["samples per arm: \(samples)"]
|
||||
for (label, door) in doors {
|
||||
var unlisted: [UInt64] = []
|
||||
var missing: [UInt64] = []
|
||||
for i in 0..<samples {
|
||||
// Interleaved, so drift in the machine lands on both arms.
|
||||
unlisted.append(await clock { await door("hidden", i) })
|
||||
missing.append(await clock { await door("nobody\(i % 7)", i) })
|
||||
}
|
||||
let floor = Discoverability.notFoundFloorNanos
|
||||
#expect(unlisted.allSatisfy { $0 >= floor }, "\(label): an unlisted answer beat the floor")
|
||||
#expect(missing.allSatisfy { $0 >= floor }, "\(label): a missing answer beat the floor")
|
||||
let (su, sm) = (Stats(unlisted), Stats(missing))
|
||||
report.append("\(label)")
|
||||
report.append(" unlisted \(su)")
|
||||
report.append(" missing \(sm)")
|
||||
report.append(String(format: " median separation %.1f us", su.median - sm.median))
|
||||
}
|
||||
|
||||
// Underneath: the lookup with no floor, which is what would show.
|
||||
var rawUnlisted: [UInt64] = []
|
||||
var rawMissing: [UInt64] = []
|
||||
var rawListed: [UInt64] = []
|
||||
for i in 0..<samples {
|
||||
rawUnlisted.append(await clock { _ = try? await Discoverability.listedUser(named: "hidden", on: h.db) })
|
||||
rawMissing.append(await clock { _ = try? await Discoverability.listedUser(named: "nobody\(i % 7)", on: h.db) })
|
||||
rawListed.append(await clock { _ = try? await Discoverability.listedUser(named: "filler\(i % 200)", on: h.db) })
|
||||
}
|
||||
let (ru, rm, rl) = (Stats(rawUnlisted), Stats(rawMissing), Stats(rawListed))
|
||||
report.append("unfloored Discoverability.listedUser")
|
||||
report.append(" unlisted \(ru)")
|
||||
report.append(" missing \(rm)")
|
||||
report.append(" listed \(rl)")
|
||||
report.append(String(format: " median separation unlisted-missing %.1f us", ru.median - rm.median))
|
||||
if samples >= 100 {
|
||||
print("NAME DOOR TIMING\n" + report.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import FluentPostgresDriver
|
||||
import Foundation
|
||||
@@ -177,4 +178,48 @@ struct PostgresMigrationTests {
|
||||
try await WaveZeroUpgrade.run(on: app)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wave 0b: introductions, contact links, `users.discoverable`.
|
||||
@Test("the wave 0b migrations apply to a populated database")
|
||||
func waveZeroBOnPopulatedDatabase() async throws {
|
||||
try await withPostgres { app in
|
||||
try await WaveZeroBUpgrade.run(on: app)
|
||||
}
|
||||
}
|
||||
|
||||
/// The spend is one `DELETE ... RETURNING` per table, so concurrent
|
||||
/// spends of one token on a real connection pool accept it once. SQLite
|
||||
/// serialises writers anyway; Postgres is where the race is real.
|
||||
@Test("concurrent spends of one intro token on Postgres accept it once")
|
||||
func introSpendRace() async throws {
|
||||
try await withPostgres { app in
|
||||
for m in RelayMigrations.all() { app.migrations.add(m) }
|
||||
try await app.autoMigrate()
|
||||
var raws: [Data] = []
|
||||
for n in 0..<16 {
|
||||
let raw = Data(repeating: UInt8(n), count: 32)
|
||||
raws.append(raw)
|
||||
let row = IntroToken()
|
||||
row.userID = 5
|
||||
row.tokenHash = Data(SHA256.hash(data: raw)).base64EncodedString()
|
||||
row.expiresAt = 2_000
|
||||
try await row.create(on: app.db)
|
||||
}
|
||||
let db = app.db
|
||||
let accepted = try await withThrowingTaskGroup(of: Int.self) { group in
|
||||
for raw in raws {
|
||||
for _ in 0..<6 {
|
||||
group.addTask {
|
||||
try await Routes.spendDeliveryToken(
|
||||
raw, for: 5, now: 1_000, on: db) ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
var total = 0
|
||||
for try await n in group { total += n }
|
||||
return total
|
||||
}
|
||||
#expect(accepted == raws.count, "\(accepted) spends for \(raws.count) tokens")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ struct RelayHarness: @unchecked Sendable {
|
||||
env extra: [String: String] = [:],
|
||||
databaseFile ownDatabaseFile: String? = nil,
|
||||
sharing peer: RelayHarness? = nil,
|
||||
limits injected: (any RateLimiting)? = nil
|
||||
limits injected: (any RateLimiting)? = nil,
|
||||
signing: Bool = false
|
||||
) async throws -> RelayHarness {
|
||||
let issuer = peer?.issuerKey ?? Curve25519.Signing.PrivateKey()
|
||||
let dir = peer?.attachmentDir ?? (NSTemporaryDirectory() + "fn-test-\(UUID().uuidString)")
|
||||
@@ -34,6 +35,11 @@ struct RelayHarness: @unchecked Sendable {
|
||||
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
|
||||
"FN_RELAY_AUDIENCE": "test.relay",
|
||||
]
|
||||
// The pair production runs with: the private half signs the log head
|
||||
// and handle certificates, the public half is what clients pin.
|
||||
if signing {
|
||||
env["FN_ISSUER_PRIVATE_KEY"] = issuer.rawRepresentation.base64EncodedString()
|
||||
}
|
||||
for (k, v) in extra { env[k] = v }
|
||||
let config = try Config.fromEnvironment(env)
|
||||
|
||||
@@ -230,9 +236,10 @@ func blockingShutdown(_ app: Application) {
|
||||
func withRelay(
|
||||
env: [String: String] = [:],
|
||||
limits: (any RateLimiting)? = nil,
|
||||
signing: Bool = false,
|
||||
_ body: (RelayHarness) async throws -> Void
|
||||
) async throws {
|
||||
let h = try await RelayHarness.make(env: env, limits: limits)
|
||||
let h = try await RelayHarness.make(env: env, limits: limits, signing: signing)
|
||||
do {
|
||||
try await body(h)
|
||||
} catch {
|
||||
|
||||
@@ -136,8 +136,17 @@ struct SealedSenderRateLimitTests {
|
||||
let unexpected =
|
||||
"a sealed send metered something other than recipient and "
|
||||
+ "address: \(metered.sorted())"
|
||||
// Since 2026-10-07: the recipient (counting refusals only, so
|
||||
// peeked here, not charged), the address, and the pair; the two
|
||||
// that are about the recipient keyed through `limiterSubject`
|
||||
// so no limiter key holds an account id.
|
||||
let r = h.routes
|
||||
#expect(
|
||||
Set(metered) == ["send:to:\(recipient.userID)", "send:ip:\(ip)"],
|
||||
Set(metered) == [
|
||||
"send:to:\(r.limiterSubject("send-to", String(recipient.userID)))",
|
||||
"send:ip:\(ip)",
|
||||
"send:toip:\(r.limiterSubject("send-toip", String(recipient.userID), ip))",
|
||||
],
|
||||
"\(unexpected)")
|
||||
|
||||
// Said again in the form a future change is most likely to break:
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Design B, stage B1: Listed and Unlisted, and every door that turns a
|
||||
/// username (or an id) into an account answering an unlisted one exactly as
|
||||
/// a missing one.
|
||||
@Suite("unlisted accounts")
|
||||
struct UnlistedTests {
|
||||
|
||||
private let now = W0B.now
|
||||
|
||||
@Test("accounts are listed unless they choose otherwise, and the choice is theirs")
|
||||
func defaultAndToggle() async throws {
|
||||
try await withRelay { h in
|
||||
let cred = try await W0B.account(h, id: 7, name: "ada")
|
||||
#expect(try await Discoverability.isListed(7, on: h.db))
|
||||
let a = W0B.accounts(h)
|
||||
|
||||
let me = try await a.me(W0B.basic(h, .GET, "/messenger/me", credential: cred))
|
||||
#expect(try W0B.decode(MeResponse.self, me).discoverable == true)
|
||||
|
||||
let off = try await a.updateMe(
|
||||
W0B.basic(h, .PUT, "/messenger/me", credential: cred, body: #"{"discoverable":false}"#))
|
||||
#expect(try W0B.decode(MeResponse.self, off).discoverable == false)
|
||||
#expect(try await !Discoverability.isListed(7, on: h.db))
|
||||
// Nothing else changed.
|
||||
#expect(try await User.find(7, on: h.db)?.displayName == "Ada")
|
||||
|
||||
let on = try await a.updateMe(
|
||||
W0B.basic(h, .PUT, "/messenger/me", credential: cred, body: #"{"discoverable":true}"#))
|
||||
#expect(try W0B.decode(MeResponse.self, on).discoverable == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a lookup of somebody else never carries their setting")
|
||||
func lookupCarriesNoSetting() async throws {
|
||||
try await withRelay { h in
|
||||
let cred = try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
let r = try await W0B.accounts(h).lookup(
|
||||
W0B.basic(h, .GET, "/messenger/users?slug=bea", credential: cred), now: now)
|
||||
#expect(W0B.canonical(r.body.string ?? "") == #"[{"id":8,"name":"Bea","slug":"bea"}]"#)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every door, three names: unlisted, nobody's, and listed (the control
|
||||
/// that shows the door is open at all).
|
||||
@Test("every username door answers an unlisted name as a missing one")
|
||||
func everyDoor() async throws {
|
||||
let spy = W0B.SpyLimiter()
|
||||
try await withRelay(limits: spy) { h in
|
||||
let cred = try await W0B.account(h, id: 1, name: "asker")
|
||||
try await W0B.account(h, id: 7, name: "hidden", listed: false)
|
||||
try await W0B.account(h, id: 8, name: "shown")
|
||||
for (user, device) in [(7, "aaaa-0007"), (8, "bbbb-0008")] {
|
||||
try await W0B.device(h, user: user, id: device)
|
||||
let u = try #require(try await User.find(user, on: h.db))
|
||||
u.fnid = "fn-\(user)"
|
||||
try await u.update(on: h.db)
|
||||
}
|
||||
let a = W0B.accounts(h)
|
||||
let m = W0B.mobile(h)
|
||||
|
||||
typealias Door = (String) async -> W0B.Answer
|
||||
let doors: [(String, Door)] = [
|
||||
("lookup ?slug=", { name in
|
||||
await W0B.answer {
|
||||
try await a.lookup(
|
||||
W0B.basic(h, .GET, "/messenger/users?slug=\(name)", credential: cred),
|
||||
now: now)
|
||||
}
|
||||
}),
|
||||
("POST /messenger/buddies", { name in
|
||||
await W0B.answer {
|
||||
try await m.addBuddy(
|
||||
W0B.basic(
|
||||
h, .POST, "/messenger/buddies", credential: cred,
|
||||
body: #"{"username":"\#(name)"}"#))
|
||||
}
|
||||
}),
|
||||
("GET /e2e/account/identity/:handle", { name in
|
||||
await W0B.answer {
|
||||
try await h.routes.resolveIdentity(
|
||||
h.anonymousRequest(.GET, "/e2e/account/identity/\(name)"),
|
||||
handle: name)
|
||||
}
|
||||
}),
|
||||
("POST /e2e/intro", { name in
|
||||
await W0B.answer { try await W0B.intro(h, handle: name) }
|
||||
}),
|
||||
]
|
||||
for (label, door) in doors {
|
||||
func run(_ name: String) async -> (W0B.Answer, UInt64, [String]) {
|
||||
let before = spy.keys.count
|
||||
let start = DispatchTime.now().uptimeNanoseconds
|
||||
let answer = await door(name)
|
||||
return (
|
||||
answer, DispatchTime.now().uptimeNanoseconds - start,
|
||||
Array(spy.keys[before...])
|
||||
)
|
||||
}
|
||||
let (hidden, t1, k1) = await run("hidden")
|
||||
let (missing, t2, k2) = await run("nobody")
|
||||
let (shown, _, _) = await run("shown")
|
||||
#expect(hidden == missing, "\(label): \(hidden) vs \(missing)")
|
||||
#expect(k1 == k2, "\(label): charged differently, \(k1) vs \(k2)")
|
||||
#expect(t1 >= Discoverability.notFoundFloorNanos, "\(label): unlisted under the floor")
|
||||
#expect(t2 >= Discoverability.notFoundFloorNanos, "\(label): missing under the floor")
|
||||
#expect(shown != missing, "\(label): the listed control answered as missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("GET /messenger/users/:id answers an unlisted id as a missing one, except to itself")
|
||||
func userByID() async throws {
|
||||
try await withRelay { h in
|
||||
let asker = try await W0B.account(h, id: 1, name: "asker")
|
||||
let hiddenCred = try await W0B.account(h, id: 7, name: "hidden", listed: false)
|
||||
try await W0B.account(h, id: 8, name: "shown")
|
||||
let m = W0B.mobile(h)
|
||||
func ask(_ id: Int, as cred: String) async -> (W0B.Answer, UInt64) {
|
||||
let start = DispatchTime.now().uptimeNanoseconds
|
||||
let a = await W0B.answer {
|
||||
try await m.userInfo(
|
||||
W0B.basic(h, .GET, "/messenger/users/\(id)", credential: cred),
|
||||
userID: id)
|
||||
}
|
||||
return (a, DispatchTime.now().uptimeNanoseconds - start)
|
||||
}
|
||||
let (hidden, t1) = await ask(7, as: asker)
|
||||
let (missing, t2) = await ask(999, as: asker)
|
||||
let (shown, _) = await ask(8, as: asker)
|
||||
let (self_, _) = await ask(7, as: hiddenCred)
|
||||
#expect(hidden == missing)
|
||||
#expect(hidden.status == 404)
|
||||
#expect(t1 >= Discoverability.notFoundFloorNanos && t2 >= Discoverability.notFoundFloorNanos)
|
||||
#expect(shown.status == 200)
|
||||
#expect(self_.status == 200, "an unlisted account could not see itself")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an unlisted account still finds its own name")
|
||||
func selfLookup() async throws {
|
||||
try await withRelay { h in
|
||||
let cred = try await W0B.account(h, id: 7, name: "hidden", listed: false)
|
||||
let r = try await W0B.accounts(h).lookup(
|
||||
W0B.basic(h, .GET, "/messenger/users?slug=hidden", credential: cred), now: now)
|
||||
#expect(
|
||||
W0B.canonical(r.body.string ?? "") == #"[{"id":7,"name":"Hidden","slug":"hidden"}]"#)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("existing contacts keep presence and sealed delivery when somebody goes unlisted")
|
||||
func existingContactsKeepWorking() async throws {
|
||||
try await withRelay { h in
|
||||
let viewer = try await W0B.account(h, id: 1, name: "viewer")
|
||||
try await W0B.account(h, id: 7, name: "hidden", listed: false)
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let token = W0B.random32()
|
||||
let tag = DeviceRosterTag()
|
||||
tag.userID = 7
|
||||
tag.deviceID = "aaaa-0007"
|
||||
tag.digest = W0B.sha(token)
|
||||
try await tag.create(on: h.db)
|
||||
let u = try #require(try await User.find(7, on: h.db))
|
||||
u.lastSeenAt = Presence.coarsen(Date())
|
||||
try await u.update(on: h.db)
|
||||
|
||||
let p = try await W0B.mobile(h).presenceByToken(
|
||||
W0B.basic(
|
||||
h, .POST, "/messenger/presence", credential: viewer,
|
||||
body: #"{"tokens":["\#(token.base64EncodedString())"]}"#))
|
||||
#expect(p.body.string?.contains("online") == true)
|
||||
|
||||
let capability = W0B.random32()
|
||||
let held = DeliveryToken()
|
||||
held.userID = 7
|
||||
held.tokenHash = W0B.sha(capability)
|
||||
try await held.create(on: h.db)
|
||||
let sent = try await W0B.sealedSend(
|
||||
h, to: 7, devices: ["aaaa-0007"], token: capability.base64EncodedString())
|
||||
#expect(sent.status == .ok)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("deleting an account takes its delivery tokens, introductions and links with it")
|
||||
func deletion() async throws {
|
||||
try await withRelay { h in
|
||||
let cred = try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
for user in [7, 8] {
|
||||
let t = DeliveryToken()
|
||||
t.userID = user
|
||||
t.tokenHash = W0B.sha(W0B.random32())
|
||||
try await t.create(on: h.db)
|
||||
let i = IntroToken()
|
||||
i.userID = user
|
||||
i.tokenHash = W0B.sha(W0B.random32())
|
||||
i.expiresAt = now + 600
|
||||
try await i.create(on: h.db)
|
||||
let l = ContactLink()
|
||||
l.userID = user
|
||||
l.linkHash = W0B.sha(W0B.random32())
|
||||
try await l.create(on: h.db)
|
||||
}
|
||||
_ = try await W0B.accounts(h).deleteAccount(
|
||||
W0B.basic(h, .DELETE, "/messenger/account", credential: cred))
|
||||
#expect(try await DeliveryToken.query(on: h.db).all().map(\.userID) == [8])
|
||||
#expect(try await IntroToken.query(on: h.db).all().map(\.userID) == [8])
|
||||
#expect(try await ContactLink.query(on: h.db).all().map(\.userID) == [8])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Design B, stage B2: contact links.
|
||||
@Suite("contact links")
|
||||
struct ContactLinkTests {
|
||||
|
||||
static func links(
|
||||
_ h: RelayHarness, as d: RelayHarness.TestDevice,
|
||||
add: [String] = [], revoke: [String] = []
|
||||
) async throws -> Response {
|
||||
let body = Data(
|
||||
"""
|
||||
{"add":[\(add.map { "\"\($0)\"" }.joined(separator: ","))],
|
||||
"revoke":[\(revoke.map { "\"\($0)\"" }.joined(separator: ","))]}
|
||||
""".utf8)
|
||||
let req = h.request(.POST, "/e2e/contact-links", as: d, body: body, now: W0B.now)
|
||||
return try await h.routes.setContactLinks(req, body: body, now: W0B.now)
|
||||
}
|
||||
|
||||
@Test("at most five links, refused rather than dropped, and revocable")
|
||||
func cap() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
let d = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let hashes = (0..<6).map { _ in W0B.sha(W0B.random32()) }
|
||||
let five = try await Self.links(h, as: d, add: Array(hashes[..<5]))
|
||||
#expect(try W0B.decode(ContactLinksResponse.self, five).links == 5)
|
||||
|
||||
let sixth = await W0B.answer { try await Self.links(h, as: d, add: [hashes[5]]) }
|
||||
#expect(sixth.status == 409)
|
||||
#expect(try await ContactLink.query(on: h.db).count() == 5)
|
||||
|
||||
// Re-adding one already held is not a sixth.
|
||||
let again = try await Self.links(h, as: d, add: [hashes[0]])
|
||||
#expect(try W0B.decode(ContactLinksResponse.self, again).links == 5)
|
||||
|
||||
let swapped = try await Self.links(h, as: d, add: [hashes[5]], revoke: [hashes[0]])
|
||||
#expect(try W0B.decode(ContactLinksResponse.self, swapped).links == 5)
|
||||
let held = Set(try await ContactLink.query(on: h.db).all().map(\.linkHash))
|
||||
#expect(held == Set(hashes[1...]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("one account cannot revoke or take over another's link")
|
||||
func ownership() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
try await W0B.account(h, id: 8, name: "bea")
|
||||
let ada = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let bea = try await W0B.device(h, user: 8, id: "bbbb-0008")
|
||||
let hash = W0B.sha(W0B.random32())
|
||||
_ = try await Self.links(h, as: ada, add: [hash])
|
||||
_ = try await Self.links(h, as: bea, revoke: [hash])
|
||||
let taken = await W0B.answer { try await Self.links(h, as: bea, add: [hash]) }
|
||||
#expect(taken.status == 409)
|
||||
#expect(try await ContactLink.query(on: h.db).all().map(\.userID) == [7])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the table holds a hash and an account, and no time or user of the link")
|
||||
func columns() async throws {
|
||||
try await withRelay { h in
|
||||
let sql = try #require(h.db as? any SQLDatabase)
|
||||
struct Column: Decodable { let name: String }
|
||||
let columns = try await sql.raw("PRAGMA table_info(contact_links)")
|
||||
.all(decoding: Column.self).map(\.name)
|
||||
#expect(Set(columns) == ["id", "user_id", "link_hash"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a malformed hash is refused")
|
||||
func malformed() async throws {
|
||||
try await withRelay { h in
|
||||
try await W0B.account(h, id: 7, name: "ada")
|
||||
let d = try await W0B.device(h, user: 7, id: "aaaa-0007")
|
||||
let bad = await W0B.answer { try await Self.links(h, as: d, add: ["short"]) }
|
||||
#expect(bad.status == 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Shared by the wave 0b suites: introductions, Unlisted, contact links,
|
||||
/// anonymous reads.
|
||||
enum W0B {
|
||||
static let now = 1_700_000_000
|
||||
|
||||
/// An account, optionally unlisted, and the app credential its client
|
||||
/// would hold.
|
||||
@discardableResult
|
||||
static func account(
|
||||
_ h: RelayHarness, id: Int, name: String, listed: Bool = true
|
||||
) async throws -> String {
|
||||
let u = User()
|
||||
u.id = id
|
||||
u.username = name
|
||||
u.email = "\(name)@example.com"
|
||||
u.passwordHash = "x"
|
||||
u.displayName = name.capitalized
|
||||
u.chosenStatus = ChosenStatus.online.rawValue
|
||||
try await u.create(on: h.db)
|
||||
if !listed { try await Discoverability.set(id, listed: false, on: h.db) }
|
||||
|
||||
let selector = String(randomHexToken().prefix(12))
|
||||
let secret = randomHexToken()
|
||||
let c = AppCredential()
|
||||
c.userID = id
|
||||
c.selector = selector
|
||||
// The cheapest cost Vapor's Bcrypt takes: these tests are not about
|
||||
// the hash, and a dozen accounts at the default cost is seconds.
|
||||
c.secretHash = try Bcrypt.hash(secret, cost: 4)
|
||||
c.label = "phone"
|
||||
try await c.create(on: h.db)
|
||||
return "\(selector).\(secret)"
|
||||
}
|
||||
|
||||
/// A registered device with `prekeys` one-time prekeys waiting.
|
||||
@discardableResult
|
||||
static func device(
|
||||
_ h: RelayHarness, user: Int, id: String, prekeys: Int = 5
|
||||
) async throws -> RelayHarness.TestDevice {
|
||||
let d = h.device(user: user, id: id)
|
||||
try await h.register(d, on: h.db)
|
||||
for n in 0..<prekeys {
|
||||
let k = PreKey()
|
||||
k.deviceID = id
|
||||
k.preKeyID = n + 1
|
||||
k.pub = "opk-\(n + 1)"
|
||||
k.used = false
|
||||
try await k.create(on: h.db)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
static func basic(
|
||||
_ h: RelayHarness, _ method: HTTPMethod, _ path: String,
|
||||
credential: String, body: String? = nil
|
||||
) -> Request {
|
||||
var headers = HTTPHeaders()
|
||||
headers.basicAuthorization = .init(username: "any", password: credential)
|
||||
var buf = ByteBufferAllocator().buffer(capacity: 0)
|
||||
if let body {
|
||||
headers.contentType = .json
|
||||
buf.writeString(body)
|
||||
}
|
||||
return Request(
|
||||
application: h.app, method: method, url: URI(string: path),
|
||||
headers: headers, collectedBody: buf,
|
||||
on: h.app.eventLoopGroup.next())
|
||||
}
|
||||
|
||||
static func accounts(_ h: RelayHarness) -> AccountRoutes {
|
||||
AccountRoutes(
|
||||
issuer: Issuer(
|
||||
signingKeyRaw: Data(repeating: 1, count: 32),
|
||||
audience: "test.relay", lifetime: 300),
|
||||
limits: h.routes.limits, signupMaxPerHour: 100)
|
||||
}
|
||||
|
||||
static func mobile(_ h: RelayHarness) -> MobileRoutes {
|
||||
MobileRoutes(accounts: accounts(h), limits: h.routes.limits)
|
||||
}
|
||||
|
||||
/// What a client receives: status and the exact body bytes, whether the
|
||||
/// route returned or threw. A thrown `WireError` is rendered the way the
|
||||
/// error middleware renders it, as the JSON of the error itself.
|
||||
struct Answer: Equatable, CustomStringConvertible {
|
||||
let status: Int
|
||||
let body: String
|
||||
var description: String { "\(status) \(body)" }
|
||||
}
|
||||
|
||||
static func answer(_ call: () async throws -> Response) async -> Answer {
|
||||
do {
|
||||
let r = try await call()
|
||||
return Answer(status: Int(r.status.code), body: canonical(r.body.string ?? ""))
|
||||
} catch let e as WireError {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let bytes = (try? encoder.encode(e)).map { String(decoding: $0, as: UTF8.self) }
|
||||
return Answer(status: e.data.status, body: bytes ?? "?")
|
||||
} catch {
|
||||
return Answer(status: -1, body: "\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay's encoder does not sort keys, so two encodings of one value
|
||||
/// can differ in key order alone. Compared after sorting, which is a
|
||||
/// difference in nothing a client reads.
|
||||
static func canonical(_ json: String) -> String {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)),
|
||||
let data = try? JSONSerialization.data(
|
||||
withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])
|
||||
else { return json }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
/// POST /e2e/intro.
|
||||
static func intro(
|
||||
_ h: RelayHarness, handle: String? = nil, link: String? = nil,
|
||||
ip: String = "198.51.100.7", now: Int = now
|
||||
) async throws -> Response {
|
||||
var fields: [String] = []
|
||||
if let handle { fields.append("\"handle\":\"\(handle)\"") }
|
||||
if let link { fields.append("\"link\":\"\(link)\"") }
|
||||
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)
|
||||
}
|
||||
|
||||
static func decode<T: Decodable>(_ type: T.Type, _ r: Response) throws -> T {
|
||||
try JSONDecoder().decode(type, from: Data((r.body.string ?? "").utf8))
|
||||
}
|
||||
|
||||
/// A sealed send with one capability, one envelope per device.
|
||||
static func sealedSend(
|
||||
_ h: RelayHarness, to user: Int, devices: [String], token: String,
|
||||
ip: String = "198.51.100.7", now: Int = now
|
||||
) async throws -> Response {
|
||||
let envelopes = devices.map { #"{"to_device":"\#($0)","payload":"ct"}"# }
|
||||
let body = Data(
|
||||
#"{"to":\#(user),"messages":[\#(envelopes.joined(separator: ","))],"delivery_token":"\#(token)"}"#
|
||||
.utf8)
|
||||
let req = h.anonymousRequest(.POST, "/e2e/messages", body: body)
|
||||
return try await h.routes.send(req, body: body, clientIP: ip, now: now)
|
||||
}
|
||||
|
||||
static func random32() -> Data {
|
||||
SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
static func sha(_ d: Data) -> String {
|
||||
Data(SHA256.hash(data: d)).base64EncodedString()
|
||||
}
|
||||
|
||||
/// A limiter that remembers every key it was asked about.
|
||||
final class SpyLimiter: RateLimiting, @unchecked Sendable {
|
||||
private let inner = RateLimiter()
|
||||
private let lock = NSLock()
|
||||
private var _keys: [String] = []
|
||||
var keys: [String] { lock.withLock { _keys } }
|
||||
private func note(_ key: String) { lock.withLock { _keys.append(key) } }
|
||||
|
||||
func consume(_ key: String, amount: Int, limit: Int, now: Int) async -> Bool {
|
||||
note(key)
|
||||
return await inner.consume(key, amount: amount, limit: limit, now: now)
|
||||
}
|
||||
func consumeAll(
|
||||
_ requests: [(key: String, amount: Int, limit: Int)], now: Int
|
||||
) async -> Bool {
|
||||
for r in requests { note(r.key) }
|
||||
return await inner.consumeAll(requests, now: now)
|
||||
}
|
||||
func peek(_ key: String, limit: Int, now: Int) async -> Bool {
|
||||
note(key)
|
||||
return await inner.peek(key, limit: limit, now: now)
|
||||
}
|
||||
func clear(_ key: String) async {
|
||||
note(key)
|
||||
await inner.clear(key)
|
||||
}
|
||||
func retryAfter(_ keys: [String], now: Int) async -> Int {
|
||||
for k in keys { note(k) }
|
||||
return await inner.retryAfter(keys, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import FluentSQLiteDriver
|
||||
import Foundation
|
||||
@@ -109,3 +110,121 @@ struct WaveZeroSQLiteUpgradeTests {
|
||||
try await app.asyncShutdown()
|
||||
}
|
||||
}
|
||||
|
||||
/// The same question for wave 0b (introductions, contact links, Listed and
|
||||
/// Unlisted): everything before it, rows of production's shape, then the rest.
|
||||
enum WaveZeroBUpgrade {
|
||||
|
||||
static func cut(_ all: [any AsyncMigration]) -> Int? {
|
||||
all.firstIndex { $0 is CreateIntroTokens }
|
||||
}
|
||||
|
||||
static func run(on app: Application) async throws {
|
||||
let all = RelayMigrations.all()
|
||||
guard let cut = cut(all) else {
|
||||
Issue.record("the wave 0b migrations are not in the boot list")
|
||||
return
|
||||
}
|
||||
#expect(all[cut...].count == 3, "wave 0b is no longer the end of the list")
|
||||
for m in all[..<cut] { app.migrations.add(m) }
|
||||
try await app.autoMigrate()
|
||||
|
||||
guard let sql = app.db as? any SQLDatabase else {
|
||||
Issue.record("not an SQL database")
|
||||
return
|
||||
}
|
||||
// Accounts and an unspent delivery token, in the columns as they
|
||||
// stood before the wave.
|
||||
try await sql.raw(
|
||||
"""
|
||||
INSERT INTO users (username, email, password_hash, display_name, chosen_status)
|
||||
VALUES ('old', 'old@example.com', 'x', 'Old', 'online'),
|
||||
('older', 'older@example.com', 'x', 'Older', 'online')
|
||||
"""
|
||||
).run()
|
||||
try await sql.raw(
|
||||
"INSERT INTO delivery_tokens (user_id, token_hash) VALUES (1, 'unspent')"
|
||||
).run()
|
||||
|
||||
for m in all[cut...] { app.migrations.add(m) }
|
||||
try await app.autoMigrate()
|
||||
|
||||
// Every existing account is listed, and the flag reads and writes.
|
||||
let old = try #require(try await User.query(on: app.db).filter(\.$username == "old").first())
|
||||
let oldID = try #require(old.id)
|
||||
#expect(try await Discoverability.isListed(oldID, on: app.db))
|
||||
#expect(try await Discoverability.listedUser(named: "older", on: app.db) != nil)
|
||||
try await Discoverability.set(oldID, listed: false, on: app.db)
|
||||
#expect(try await !Discoverability.isListed(oldID, on: app.db))
|
||||
#expect(try await Discoverability.listedUser(named: "old", on: app.db) == nil)
|
||||
// A new account made through the model gets the default.
|
||||
let fresh = User()
|
||||
fresh.username = "fresh"
|
||||
fresh.email = "fresh@example.com"
|
||||
fresh.passwordHash = "x"
|
||||
fresh.displayName = "Fresh"
|
||||
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()
|
||||
intro.userID = oldID
|
||||
intro.tokenHash = Data(SHA256.hash(data: raw)).base64EncodedString()
|
||||
intro.expiresAt = 2_000
|
||||
try await intro.create(on: app.db)
|
||||
let link = ContactLink()
|
||||
link.userID = oldID
|
||||
link.linkHash = "link"
|
||||
try await link.create(on: app.db)
|
||||
#expect(try await Routes.spendDeliveryToken(raw, for: oldID, now: 1_000, on: app.db))
|
||||
#expect(try await !Routes.spendDeliveryToken(raw, for: oldID, now: 1_000, on: app.db))
|
||||
// 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.
|
||||
try await app.db.withConnection { conn in
|
||||
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: app.db).count() == 3)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("the wave 0b migrations upgrade a SQLite database")
|
||||
struct WaveZeroBSQLiteUpgradeTests {
|
||||
/// On a file, which is what a SQLite relay runs on.
|
||||
@Test("the wave 0b migrations apply to a populated database")
|
||||
func populated() async throws {
|
||||
let file = NSTemporaryDirectory() + "fn-w0b-\(UUID().uuidString).sqlite"
|
||||
let app = try await Application.make(.testing)
|
||||
app.logger.logLevel = .critical
|
||||
app.databases.use(.sqlite(.file(file)), as: .sqlite)
|
||||
do {
|
||||
try await WaveZeroBUpgrade.run(on: app)
|
||||
} catch {
|
||||
try? await app.asyncShutdown()
|
||||
try? FileManager.default.removeItem(atPath: file)
|
||||
throw error
|
||||
}
|
||||
try await app.asyncShutdown()
|
||||
try? FileManager.default.removeItem(atPath: file)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user