It is also genuinely closed rather than nominally closed. The relay
+has one piece of outbound network code in it — the SMTP client for
+password reset mail — and that is disabled unless you configure a mail
+server. With FN_SMTP_HOST unset and federation closed, the
+process makes no outbound connections at all. It talks
+to its database and to whoever dials it, and to nothing else. There is
+no telemetry, no update check, and no directory to register with.
+Choose it deliberately. If your relay exists so that one household,
+one company or one group of friends can talk without anyone else
+involved, closed is not the cautious option — it is the
+correct one.
+It removes the operator from your threat model, because you are the
diff --git a/Resources/Views/source.leaf b/Resources/Views/source.leaf
index 501d597..21f00a8 100644
--- a/Resources/Views/source.leaf
+++ b/Resources/Views/source.leaf
@@ -4,15 +4,22 @@
Source code
- The engine and the relay are published under the MIT licence. Everything this site
- describes is in one repository.
+ The protocol, the relay and the clients are published under the MIT licence.
+ Four repositories, because most people want one piece rather than all of it.
-
What is in it
+
Where things are
- - engine/ — the JavaScript cryptographic engine, its test suite, and the published test vectors
- - engine/docs/SPEC.md — the protocol specification, rendered on this site
- - server/ — the Swift relay that serves this page
+ - flamenet-e2e
+ — the JavaScript engine, the specification, and the published
+ test vectors every implementation is held to
+ - flamenet-relay
+ — the Swift relay that serves this page. What you run to
+ self-host
+ - flamenet-messenger-ios
+ — the iOS app, and the source of truth for all of the above
+ - flamenet-messenger-android
+ — the Kotlin engine. Not an app yet, and the roadmap says so
Reading it without cloning it
@@ -24,12 +31,8 @@
engine and
relay overviews.
-
The repository
- gitlab.com/paulhitt/flamenet-e2e
-
-
- That and the hosting credit in the footer are the only two links on this
+ Those and the hosting credit in the footer are the only links on this
site that lead off it. Everything else this project has to say is a page
here, which is the point: a project asking to be taken on its own terms
should not send you elsewhere to read them.
diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf
index 88c366e..dad0f7a 100644
--- a/Resources/Views/spec.leaf
+++ b/Resources/Views/spec.leaf
@@ -448,6 +448,56 @@ time-limited credentials for the coturn relay. Clients put both STUN and
TURN entries in their ICE configuration; media flows peer-to-peer when
NATs allow and falls back to the relay (which sees only DTLS-SRTP
ciphertext).
+
6.9 Message ids, replies,
+and reactions
+
A reply or a reaction has to name an earlier message, and until this
+section there was nothing to name it by: message ids were per-device
+counters assigned on receipt, so two devices held different numbers for
+the same message.
+
mid. A sender MAY put an id on a
+message it sends, in the content envelope:
+
{ "fnc": 1, "kind": "text", "mid": "<uuid>", "caption": "hello" }
+
It MUST be unique within the conversation; a UUID is the obvious
+choice. A message without a mid is still valid — plain
+UTF-8 with no envelope remains a legal message — but nothing can refer
+to it, so it cannot be reacted to. Implementations
+SHOULD put a mid on every message they send.
+
re — what a message is about. Present
+on a reply and on a reaction:
+
{ "mid": "<the id being answered>", "author": 12, "preview": "what they said" }
+
preview is REQUIRED and carries the sender's own copy of
+the quoted text, truncated. This is deliberate redundancy. A recipient
+device may have joined the conversation after the quoted message, or
+cleared its history, and would then have nothing to render; a reply that
+shows > [missing] is worse than one that shows what was
+actually said. The preview is what the sender saw, which is the
+only copy either party can be sure of. A receiving client MUST render
+the preview as supplied and MUST NOT treat it as authenticated content
+attributable to the quoted author — it is the replier's assertion about
+what was said, and a replier can lie about it. Clients SHOULD render it
+as a quote attributed to the replier's view, not as the original
+author's words.
+
Reactions. kind is
+"react", caption is the emoji, re
+names the target:
+
{ "fnc": 1, "kind": "react", "caption": "👍", "re": { "mid": "<target>", "author": 12, "preview": "" } }
+
An empty caption removes this sender's
+reaction from that message. Removal is a message rather than an absence
+because the other device has already rendered the first one and there is
+no other way to reach it.
+
A receiving client MUST NOT render a reaction as a message. It is
+folded onto the target and, if the target is unknown, dropped. It MUST
+NOT raise a notification: being buzzed for a thumbs-up is the thing
+people turn a messenger off over. One reaction per sender per message —
+a second one replaces the first.
+
gid applies as elsewhere: a reaction on a group message
+carries the group id and routes to the group thread.
+
Status. Implemented in the iOS engine and the
+JavaScript engine, and checked against each other in both directions by
+test/interop.mjs — including a preview containing quotes
+and a newline, which is where two hand-ordered JSON writers drift apart.
+The Kotlin engine does not implement this section,
+along with most of the rest of the protocol.
7. Safety numbers
(verification)
For a local identity IK_sig_local_pub and remote
@@ -1846,12 +1896,55 @@ records. A republication that does not change the identity key (prekey
replenishment, SPK rotation) appends nothing: entries that mean
something would otherwise be buried under entries that do not.
19.2 Leaf contents
-
leaf = SHA-256( 0x00 || "v1|" || kind || "|" || user_id || "|" || device_id
+leaf = SHA-256( 0x00 || "v2|" || kind || "|" || subject || "|" || device_id
|| "|" || ik_sig || "|" || ik_dh )
kind is publish or revoke.
Built by concatenation rather than by a JSON encoder: a leaf hash must
be reproducible by every independent verifier forever, and an encoder is
free to change key order, spacing or escaping between releases.
+subject replaced user_id in
+v2. A relay's user_id is a row number it assigned
+to itself. It made a leaf unverifiable by exactly the third-party
+monitors this section exists to enable — they have no way to learn what
+user 12 means — and it made two relays unable to agree that two leaves
+describe the same person, which is the first thing federation needs.
+A subject is one of two shapes, and which one it is is readable from
+the string:
+
+
+
+| Form |
+Meaning |
+
+
+
+
+fnid:<crockford-base32> |
+A global account identity: the account's Ed25519 public key.
+Portable — the same person at another relay produces the same
+subject. |
+
+
+local:<audience>/<user_id> |
+A relay-local row number, qualified by the relay that assigned
+it. |
+
+
+
+The local: form exists because a device may be
+registered before its account key has been claimed. Refusing to log
+those devices would leave a hole in the one record that must not have
+holes; logging them unqualified would produce leaves that mean different
+things depending on their origin, with nothing in the leaf to say so.
+Qualifying it states plainly that the leaf is relay-scoped.
+⚠️ A v1 leaf must never be verified under v2 rules.
+The version prefix is inside the hashed bytes precisely so the two
+cannot be confused — a verifier that recomputes a v1 leaf with a v2
+template gets a different hash and correctly fails, rather than silently
+accepting a leaf about a different subject. Relays that ran v1 keep
+those leaves, hashes untouched, so consistency proofs across the format
+change still hold: the log is append-only, and a format change is not a
+licence to rewrite history.
19.3 Routes
All four are unauthenticated. A log only its own
users can read is not a transparency log; the value is that anyone — a
diff --git a/Scripts/build-doc-pages.sh b/Scripts/build-doc-pages.sh
index 26ba548..ac7d238 100755
--- a/Scripts/build-doc-pages.sh
+++ b/Scripts/build-doc-pages.sh
@@ -32,4 +32,7 @@ render security "$repo/engine/SECURITY.md"
render self-hosting "$repo/engine/SELF-HOSTING.md"
render engine "$repo/engine/README.md"
render relay "$here/README.md"
+render api "$here/docs/API.md"
+render configuration "$here/docs/CONFIGURATION.md"
+render hosting "$here/docs/HOSTING.md"
echo "done."
diff --git a/Scripts/secret-sweep.sh b/Scripts/secret-sweep.sh
index 4724106..fb62934 100755
--- a/Scripts/secret-sweep.sh
+++ b/Scripts/secret-sweep.sh
@@ -57,7 +57,11 @@ probe "Slack token" 'xox[baprs]-[0-9A-Za-z-]{10,}'
probe "private key block" '-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY'
probe "JWT" 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.'
probe "Supabase service role" 'service_role.{0,40}eyJ'
-probe "assigned password" '(password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"'{}$][^"'"'"']{7,}["'"'"']'
+# The excluded first characters are interpolation, not credentials: `${...}` in
+# shell and JavaScript, and `\(...)` in Swift. A value the program computes at
+# run time cannot be a literal secret, and flagging one trains people to pass
+# the gate by ignoring it.
+probe "assigned password" '(password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"'{}$\\][^"'"'"']{7,}["'"'"']'
if [ "$hits" -gt 0 ]; then
echo
diff --git a/Sources/FlamenetRelay/Account.swift b/Sources/FlamenetRelay/Account.swift
index 5933960..39391e0 100644
--- a/Sources/FlamenetRelay/Account.swift
+++ b/Sources/FlamenetRelay/Account.swift
@@ -6,7 +6,7 @@ import Vapor
/// An account on this instance.
///
/// This is the *built-in* identity provider — the one that makes self-hosting
-/// stop meaning "run and secure a WordPress install". On flamenet.io WordPress
+/// stop meaning "run and secure a the previous platform install". On flamenet.io the previous platform
/// remains the issuer and none of this is used; the relay does not care which
/// one signed a token, because it only ever checks a pinned public key.
final class User: Model, @unchecked Sendable {
@@ -37,6 +37,23 @@ final class User: Model, @unchecked Sendable {
/// for accounts created directly here, which is what a self-hosted relay
/// has.
@OptionalField(key: "enclave_pseudonym") var enclavePseudonym: String?
+
+ /// The account's Ed25519 public key, base64, and the address derived from
+ /// it. Both null until a client publishes one.
+ ///
+ /// Unlike `enclavePseudonym` above, this is not something any server
+ /// issued: the client stretches it from the passphrase and proves
+ /// possession to register it. Storing it here lets this relay address the
+ /// account the way another relay would, which is the whole prerequisite
+ /// for federating anything.
+ ///
+ /// Kept alongside the derived `fnid` rather than deriving on read, because
+ /// the address is what gets indexed, compared and put in a log leaf, and
+ /// recomputing it on every one of those is a base32 encode per row for a
+ /// value that cannot change.
+ @OptionalField(key: "account_key") var accountKey: String?
+ @OptionalField(key: "fnid") var fnid: String?
+
@OptionalField(key: "last_seen_at") var lastSeenAt: Date?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@@ -65,7 +82,7 @@ final class User: Model, @unchecked Sendable {
static func isValidUsername(_ username: String) -> Bool {
let u = normalize(username)
guard (3...60).contains(u.count) else { return false }
- // Deliberately narrower than WordPress: OSCAR rejects `.`, `_` and `-`
+ // Deliberately narrower than the previous platform: OSCAR rejects `.`, `_` and `-`
// in screen names and reports it as "service unreachable", which is
// impossible to debug from the client. Refusing them at signup is
// kinder than discovering it at first sign-on.
diff --git a/Sources/FlamenetRelay/AccountIdentity.swift b/Sources/FlamenetRelay/AccountIdentity.swift
new file mode 100644
index 0000000..dd73328
--- /dev/null
+++ b/Sources/FlamenetRelay/AccountIdentity.swift
@@ -0,0 +1,133 @@
+import Crypto
+import Foundation
+
+/// A global account identity, for a network with more than one relay in it.
+///
+/// `user_id` is a row number this relay assigned. It is meaningful here and
+/// nowhere else: user 12 on this relay and user 12 on another are unrelated
+/// people, so nothing addressed by it can cross a relay boundary. The shared
+/// account service's pseudonym is no better for the purpose — it is
+/// `HMAC(service_secret, account ‖ product)`, deliberately scoped to one
+/// service so two products cannot correlate their users, which also means two
+/// relays cannot agree on it.
+///
+/// So the global identity is a key the *client* derives and holds: an Ed25519
+/// account key, stretched from the passphrase under its own context, the same
+/// way the per-product encryption keys already are. The public half, rendered
+/// as an `fnid`, is the address.
+///
+/// The property that matters for redundancy is that no server issues it. A
+/// relay can go away — be shut down, be deleted, refuse to serve you — without
+/// taking the identity with it, because the identity was never the relay's to
+/// grant. Re-registering the same account key at a new relay produces the same
+/// fnid, and contacts who pinned it are still talking to the same person.
+enum FNID {
+
+ /// Crockford base32: no padding, no `I`, `L`, `O` or `U`, so the alphabet
+ /// has no character pair a person can confuse when reading one aloud or
+ /// copying it off a screen.
+ ///
+ /// Chosen to match the recovery codes the Files product already issues
+ /// rather than introducing a second encoding for humans to handle. Standard
+ /// base64 was the alternative and is worse here for exactly one reason:
+ /// these end up in URLs, QR codes and spoken verification, and `+/=` do not
+ /// survive any of those unescaped.
+ static let alphabet = Array("0123456789abcdefghjkmnpqrstvwxyz")
+
+ /// The 32 raw bytes of an Ed25519 public key encode to 52 characters.
+ static let encodedLength = 52
+
+ static func encode(_ data: Data) -> String {
+ var out = ""
+ var buffer = 0
+ var bits = 0
+ for byte in data {
+ buffer = (buffer << 8) | Int(byte)
+ bits += 8
+ while bits >= 5 {
+ bits -= 5
+ out.append(alphabet[(buffer >> bits) & 31])
+ }
+ }
+ if bits > 0 { out.append(alphabet[(buffer << (5 - bits)) & 31]) }
+ return out
+ }
+
+ /// The address for an account key, as it appears on the wire and in a log
+ /// leaf.
+ ///
+ /// Prefixed rather than bare so that a bare 52-character string is never
+ /// mistaken for one of these by something doing a loose comparison, and so
+ /// a future encoding can be told apart from this one without guessing.
+ static func address(forAccountKey raw: Data) -> String {
+ "fnid:" + encode(raw)
+ }
+
+ /// What a client signs with its account private key to claim an fnid here.
+ ///
+ /// Three things are bound, and dropping any one of them breaks something:
+ ///
+ /// - The **audience**, so a claim captured at one relay cannot be replayed
+ /// at another. The same account key is *meant* to be registered at many
+ /// relays — that is the whole point — but each registration must be an
+ /// act the account holder took at that relay, not one a third party
+ /// replayed on their behalf.
+ /// - The **local user id**, so a claim cannot be lifted onto a different
+ /// account on this same relay.
+ /// - The **key itself**, so the signature cannot be transplanted onto a
+ /// different key by anyone who intercepts it.
+ ///
+ /// Built by concatenation for the same reason log leaves are: this string
+ /// has to be reproducible byte-for-byte by three independent client
+ /// implementations, and a JSON encoder is free to reorder or respace.
+ static func claimCanonical(audience: String, userID: Int, accountKeyB64: String) -> Data {
+ Data("fnacct-v1|\(audience)|\(userID)|\(accountKeyB64)".utf8)
+ }
+
+ /// Verify a claim signature against the key it is claiming.
+ ///
+ /// Self-certifying: the signature is checked with the very key being
+ /// registered, so the relay is confirming possession rather than vouching
+ /// for identity. It has no opinion about who the key belongs to and cannot
+ /// acquire one — which is the property that lets a hostile relay be
+ /// tolerated at all.
+ static func verifyClaim(
+ accountKeyB64: String, signatureB64: String, audience: String, userID: Int
+ ) -> Data? {
+ guard let raw = Data(base64Encoded: accountKeyB64), raw.count == 32,
+ let sig = Data(base64Encoded: signatureB64),
+ let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw)
+ else { return nil }
+ let canonical = claimCanonical(audience: audience, userID: userID, accountKeyB64: accountKeyB64)
+ guard key.isValidSignature(sig, for: canonical) else { return nil }
+ return raw
+ }
+}
+
+/// What a key transparency leaf names when it says whose key this is.
+///
+/// The log predates any global identity, so its leaves bound `user_id` — which
+/// makes a leaf verifiable only by someone who already knows which relay issued
+/// it, and meaningless to the third-party monitors §19 exists to enable.
+///
+/// A subject is one of two shapes, and which one it is can be read off the
+/// string rather than inferred:
+///
+/// - `fnid:` — a global account identity. Portable: a monitor can check
+/// it against entries in any other relay's log for the same person.
+/// - `local:/` — this relay's own row number, qualified by
+/// the relay that assigned it.
+///
+/// The `local:` form exists because a device can be registered before its
+/// account key has been published, and the alternative was worse in both
+/// directions: refusing to log those devices leaves a hole in exactly the
+/// record that is supposed to have none, and logging them unqualified produces
+/// leaves that silently mean different things depending on where they came
+/// from. Qualifying it says plainly that the leaf is relay-scoped, which a
+/// verifier can then decide what to do about.
+enum LogSubject {
+ static func forAccount(fnid: String?, audience: String, userID: Int) -> String {
+ if let fnid, !fnid.isEmpty { return fnid }
+ return "local:\(audience)/\(userID)"
+ }
+}
diff --git a/Sources/FlamenetRelay/AtRestCrypto.swift b/Sources/FlamenetRelay/AtRestCrypto.swift
index 58670a4..d656be6 100644
--- a/Sources/FlamenetRelay/AtRestCrypto.swift
+++ b/Sources/FlamenetRelay/AtRestCrypto.swift
@@ -19,7 +19,7 @@ import Foundation
///
/// Blob layout is byte-compatible with the PHP implementation
/// (`base64(iv[12] || tag[16] || ciphertext)`) so rows can be carried across
-/// from a WordPress install without re-encryption. Note the ordering: this is
+/// from a the previous platform install without re-encryption. Note the ordering: this is
/// **not** what `AES.GCM.SealedBox.combined` produces, which puts the tag last.
/// Assembling by hand is deliberate, not an oversight.
enum AtRestCrypto {
diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift
index d4f8d5f..c19ec5a 100644
--- a/Sources/FlamenetRelay/Config.swift
+++ b/Sources/FlamenetRelay/Config.swift
@@ -13,8 +13,32 @@ struct Config: Sendable {
/// Donations. Off unless an operator configures a processor: a relay that
/// someone else runs has no business soliciting money for us, and a button
/// that fails after a person has decided to give is worse than no button.
+ /// Outbound mail, for password resets. Nil disables the feature entirely
+ /// rather than half-enabling it: a reset flow that mints a token and then
+ /// cannot deliver it leaves the account locked out AND a live token in the
+ /// database, which is the worst of both.
+ let smtp: SMTPConfig?
let donateProvider: String?
let donateSecret: String?
+ /// The Stripe **connected account** donations are taken on, or nil to take
+ /// them on whichever account the secret key itself authenticates as.
+ ///
+ /// Set to an `acct_…` id and every Stripe call carries `Stripe-Account`,
+ /// which makes the charge a *direct* one: the money, the fees, the receipt
+ /// and the statement descriptor all belong to that account rather than to
+ /// the platform. Unset is not an error — it is how a self-hoster runs this
+ /// with their own key — but with a platform key it means donations land on
+ /// the platform, silently, so start-up says which way it is configured.
+ let donateAccount: String?
+ /// Stripe's publishable key, handed to the browser so it can mount the
+ /// Payment Element. Public by design — it authorises nothing on its own —
+ /// but still configuration rather than something to hardcode.
+ ///
+ /// With `donateAccount` set this is the **platform's** key, not the
+ /// connected account's: a direct charge is confirmed by the platform key
+ /// scoped with `stripeAccount`, and the connected account's own key would
+ /// be the wrong half of that pair.
+ let donatePublishableKey: String?
/// The issuer's Ed25519 public key, as raw bytes.
///
/// Bytes rather than a `Curve25519.Signing.PublicKey`, because on Linux
@@ -28,6 +52,34 @@ struct Config: Sendable {
let audience: String
let clockSkew: Int
+ /// Whether this relay will exchange traffic with other relays.
+ ///
+ /// `closed` by default, and that default is load-bearing rather than
+ /// cautious: server-to-server delivery is not implemented, so any other
+ /// value would advertise a capability with no code behind it. An operator
+ /// opting in early would produce peers that try and fail, which is worse
+ /// than peers that never try.
+ let federationPolicy: String
+
+ /// Ed25519 seed this relay signs server-to-server requests with.
+ ///
+ /// **Deliberately not the token issuer key.** Issuer.swift already notes
+ /// that co-locating the issuer means a relay compromise is an issuer
+ /// compromise; signing federation traffic with the same key would widen
+ /// that further, so that a key which only ever needed to prove "this relay
+ /// said this" could also mint a token for any user on it. A separate key
+ /// costs one more `openssl genpkey` and bounds a leak to impersonating the
+ /// relay to its peers.
+ ///
+ /// Nil is normal — a closed relay never signs anything for anyone.
+ let federationKeyRaw: Data?
+
+ /// Peers this relay will exchange traffic with under `allowlist`.
+ ///
+ /// Ignored under `closed` (nobody) and `open` (anybody), so it is not a
+ /// second switch that can silently disagree with the first.
+ let federationPeers: [String]
+
/// Bundle fetches per requester, and per requester-target pair, per hour.
let bundleRatePerHour: Int
let bundleRatePerTarget: Int
@@ -53,9 +105,9 @@ struct Config: Sendable {
/// The built-in identity provider's Ed25519 signing seed, or nil.
///
- /// Nil on flamenet.io, where WordPress is the issuer and this relay only
+ /// Nil where a separate service is the issuer and this relay only
/// ever verifies. Set for a self-hoster, for whom the alternative is not
- /// two isolated services but installing WordPress.
+ /// two isolated services.
let issuerSigningKeyRaw: Data?
let tokenLifetime: Int
@@ -117,11 +169,28 @@ struct Config: Sendable {
port: int("FN_PORT", 8080),
databasePath: env["FN_DB"] ?? "relay.sqlite",
postgresURL: env["FN_POSTGRES_URL"].flatMap { $0.isEmpty ? nil : $0 },
+ smtp: SMTPConfig(env: env),
donateProvider: env["FN_DONATE_PROVIDER"].flatMap { $0.isEmpty ? nil : $0 },
donateSecret: env["FN_DONATE_SECRET"].flatMap { $0.isEmpty ? nil : $0 },
+ donateAccount: env["FN_DONATE_ACCOUNT"].flatMap { $0.isEmpty ? nil : $0 },
+ donatePublishableKey: env["FN_DONATE_PUBLISHABLE_KEY"]
+ .flatMap { $0.isEmpty ? nil : $0 },
issuerKeyRaw: raw,
audience: audience,
clockSkew: int("FN_CLOCK_SKEW", 60),
+ federationPolicy: {
+ let v = env["FN_FEDERATION"] ?? "closed"
+ // An unrecognised value falls back to closed rather than being
+ // rejected at boot: a typo in one optional variable should not
+ // stop a relay serving its own users.
+ return ["closed", "allowlist", "open"].contains(v) ? v : "closed"
+ }(),
+ federationKeyRaw: env["FN_FEDERATION_KEY"]
+ .flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) },
+ federationPeers: (env["FN_FEDERATION_PEERS"] ?? "")
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty },
bundleRatePerHour: int("FN_BUNDLE_RATE", 120),
bundleRatePerTarget: int("FN_BUNDLE_RATE_TARGET", 6),
sendRatePerHour: int("FN_SEND_RATE", 600),
@@ -144,3 +213,40 @@ struct Config: Sendable {
)
}
}
+
+/// Outbound mail settings, for password reset.
+///
+/// All of `HOST`, `FROM` and a port are required together — there is no useful
+/// half-configured state, and defaulting a host to localhost would silently
+/// send reset links into a mail server nobody is reading.
+///
+/// Credentials are optional on purpose: a relay next to its own MTA on the same
+/// host commonly submits without auth, and demanding a username there would
+/// mean inventing one.
+struct SMTPConfig: Sendable {
+ let host: String
+ let port: Int
+ let username: String?
+ let password: String?
+ /// Envelope sender, e.g. `Flamenet `.
+ let from: String
+ /// STARTTLS on the submission port. On unless explicitly disabled, because
+ /// the failure mode of the other default is a password reset link crossing
+ /// a network in the clear.
+ let useTLS: Bool
+
+ init?(env: [String: String]) {
+ func value(_ key: String) -> String? {
+ env[key].flatMap { $0.isEmpty ? nil : $0 }
+ }
+ guard let host = value("FN_SMTP_HOST"), let from = value("FN_SMTP_FROM") else {
+ return nil
+ }
+ self.host = host
+ self.from = from
+ self.port = value("FN_SMTP_PORT").flatMap(Int.init) ?? 587
+ self.username = value("FN_SMTP_USERNAME")
+ self.password = value("FN_SMTP_PASSWORD")
+ self.useTLS = (value("FN_SMTP_TLS") ?? "true").lowercased() != "false"
+ }
+}
diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift
index a0e0fb1..7de3bfa 100644
--- a/Sources/FlamenetRelay/Entrypoint.swift
+++ b/Sources/FlamenetRelay/Entrypoint.swift
@@ -14,7 +14,7 @@ import Vapor
/// `payload` goes into a TEXT column and comes back out unread.
///
/// What it is NOT: an identity provider. Accounts, profiles, and the community
-/// they are anchored in stay in WordPress, which mints the tokens this service
+/// they are anchored in stay with the identity provider, which mints the tokens this service
/// verifies against a pinned public key and never calls on the request path.
@main
enum Entrypoint {
@@ -105,9 +105,11 @@ func configure(_ app: Application) async throws {
CreateUser(), CreateAppCredential(),
CreateBuddy(), CreateMessage(), CreateConversationState(),
CreateBlock(), CreateNotification(), CreateLogEntry(),
+ CreatePasswordResetToken(),
// Last: it alters tables the migrations above create, and Fluent runs
// them in registration order.
- TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail())
+ TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail(),
+ AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID())
// Migrating at boot rather than as a separate command: a single static
// binary that someone can run in five minutes is the point, and "run this
// other command first" is where five minutes becomes an afternoon.
@@ -127,6 +129,17 @@ func configure(_ app: Application) async throws {
// any /e2e/* route, and no /e2e/* handler reads from disk.
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
+ // Shared with the federation verifier: one window, so a nonce replayed
+ // across the client and peer paths is caught by whichever sees it second.
+ let replayCache = ReplayCache(window: config.clockSkew * 2)
+
+ // The directory is the relay's only outbound HTTP, and it is constructed
+ // only when an operator has opted in. A closed relay holds no client, which
+ // is what makes "a closed relay opens no outbound connections" a property
+ // of the code rather than a promise in a document.
+ let peerDirectory: PeerDirectory? = config.federationPolicy == "closed"
+ ? nil : PeerDirectory(client: app.client)
+
let routes = Routes(
config: config,
verifier: TokenVerifier(
@@ -134,7 +147,28 @@ func configure(_ app: Application) async throws {
audience: config.audience,
skew: config.clockSkew),
limits: RateLimiter(),
- replay: ReplayCache(window: config.clockSkew * 2)
+ federation: FederationVerifier(
+ policy: config.federationPolicy,
+ peers: config.federationPeers,
+ audience: config.audience,
+ skew: config.clockSkew,
+ directory: peerDirectory,
+ replay: replayCache),
+ federationSender: {
+ guard config.federationPolicy != "closed",
+ let seed = config.federationKeyRaw else { return nil }
+ let policy = config.federationPolicy
+ let peers = config.federationPeers
+ return FederationSender(
+ audience: config.audience,
+ signingKeyRaw: seed,
+ client: app.client,
+ // The same rule that admits a peer decides who we will dial.
+ // Two lists would eventually disagree, and the disagreement
+ // would be a relay that accepts from someone it will not answer.
+ admits: { policy == "open" || peers.contains($0) })
+ }(),
+ replay: replayCache
)
try register(routes: routes, on: app)
@@ -150,6 +184,30 @@ func configure(_ app: Application) async throws {
// The built-in identity provider, only when a signing key is configured.
//
+ // Federation, when an operator has asked for it.
+ //
+ // Refusing to boot rather than degrading to closed: an operator who set
+ // FN_FEDERATION has decided their relay should be reachable by peers, and
+ // silently starting it unreachable would be discovered by a stranger
+ // failing to deliver, not by them. `closed` remains the do-nothing default,
+ // so this can only fire for someone who opted in.
+ if config.federationPolicy != "closed" {
+ guard let seed = config.federationKeyRaw,
+ (try? Curve25519.Signing.PrivateKey(rawRepresentation: seed)) != nil
+ else {
+ app.logger.critical("""
+ FN_FEDERATION is \(config.federationPolicy) but FN_FEDERATION_KEY is not a \
+ 32-byte Ed25519 seed. Generate one with: \
+ openssl genpkey -algorithm ed25519 | openssl pkey -outform DER | tail -c 32 | base64
+ """)
+ throw Config.ConfigError.badIssuerKey
+ }
+ if config.federationPolicy == "allowlist" && config.federationPeers.isEmpty {
+ app.logger.warning(
+ "FN_FEDERATION=allowlist with no FN_FEDERATION_PEERS: this relay federates with nobody")
+ }
+ }
+
// Absent, `/account/*` does not exist at all rather than existing and
// refusing — a route that 500s because of a missing environment variable
// reads as an outage, and this is a deployment choice, not a fault.
@@ -164,7 +222,7 @@ func configure(_ app: Application) async throws {
audience: config.audience,
lifetime: config.tokenLifetime),
limits: RateLimiter(window: AccountRoutes.signinLockout))
- try register(account: accounts, on: app)
+ try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
// It authenticates with the same app credential, so it needs the
@@ -177,7 +235,7 @@ func configure(_ app: Application) async throws {
try register(mobile: mobile, on: app)
// The shipped client signs up and signs in under the messenger prefix,
- // and validates a restored session against wp/v2/users/me. Same
+ // and validates a restored session against messenger/me. Same
// handlers as /account/*, mounted where the app already looks — the
// response shape was already identical, so this is only a matter of
// where it is served from.
@@ -188,28 +246,57 @@ func configure(_ app: Application) async throws {
}
// A shared FlameNet account, when one is configured. Absent on a
// self-hosted relay, which is its own identity provider.
- if let enclaveSecret = Environment.get("FN_ENCLAVE_JWT_SECRET"), enclaveSecret.count >= 32 {
+ // A verifying key, when the account service publishes one.
+ //
+ // Configured rather than fetched: this decides who may assert an
+ // identity here, and a relay that learned that over the network could be
+ // pointed at a different issuer by whoever controls DNS. It is available
+ // at the account service's /.well-known/jwks.json — copy the `x` value
+ // in once, the way the issuer key is already handled.
+ let enclavePublicKey: Data? = Environment.get("FN_ENCLAVE_PUBLIC_KEY")
+ .flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) }
+ if let key = enclavePublicKey, key.count != 32 {
+ app.logger.critical("FN_ENCLAVE_PUBLIC_KEY is not a base64 32-byte Ed25519 public key")
+ throw Config.ConfigError.badIssuerKey
+ }
+
+ let enclaveSecret = Environment.get("FN_ENCLAVE_JWT_SECRET") ?? ""
+ // Either credential enables the route. A relay running on shared
+ // accounts needs only the public key; the secret remains accepted for
+ // deployments the account service has not moved yet.
+ if enclaveSecret.count >= 32 || enclavePublicKey != nil {
compat.post("session") { req in
- try await accounts.enclaveSession(req, secret: enclaveSecret, now: unixNow())
+ try await accounts.enclaveSession(
+ req, secret: enclaveSecret, publicKey: enclavePublicKey, now: unixNow())
+ }
+ if enclavePublicKey != nil {
+ app.logger.info("shared FlameNet account sessions enabled (signature-verified)")
+ } else {
+ app.logger.warning("""
+ Shared FlameNet account sessions are verified with a shared secret. \
+ Anything holding that secret can also mint sessions for any account, \
+ so this is only safe on a relay operated by the account service's own \
+ operator. Set FN_ENCLAVE_PUBLIC_KEY to verify signatures instead.
+ """)
}
- app.logger.info("shared FlameNet account sessions enabled")
}
// Account removal. Under the messenger prefix because that is where the
// shipped client sends it.
compat.delete("account") { req in try await accounts.deleteAccount(req) }
- let wpUsers = app.grouped("api", "wp", "v2", "users")
- wpUsers.get("me") { req in try await accounts.me(req) }
- // POST because that is what the client sends -- WordPress accepted it
- // as a PUT alias and the client was written against that. PUT is
- // registered too so the route is not surprising to anything else.
- wpUsers.post("me") { req in try await accounts.updateMe(req) }
- wpUsers.put("me") { req in try await accounts.updateMe(req) }
- // Exact-username lookup, not the collection WordPress served here. The
- // route is the same one the client already calls; what it will not do
- // is answer without a `slug`.
- wpUsers.get { req in try await accounts.lookup(req, now: unixNow()) }
+ // ⚠️ These carried a legacy route shape while the app still spoke it.
+ // Nothing of that lineage runs behind this service, so they live where
+ // everything else does.
+ let me = app.grouped("api", "flamenet", "v1", "messenger")
+ me.get("me") { req in try await accounts.me(req) }
+ // PUT is the honest verb for "replace fields on this thing". POST is
+ // registered alongside it only because clients in the wild send it.
+ me.put("me") { req in try await accounts.updateMe(req) }
+ me.post("me") { req in try await accounts.updateMe(req) }
+ // Exact-username lookup, and deliberately not a directory: it will not
+ // answer without a `slug`.
+ me.get("users") { req in try await accounts.lookup(req, now: unixNow()) }
// The namespace index, so a client can find out what this relay serves
// instead of assuming.
//
@@ -220,17 +307,27 @@ func configure(_ app: Application) async throws {
// its default of "commands work", and every one of them fails at the
// point a person tries to use it -- which also leaks the query, since
// command searches travel in the clear.
- for prefix in ["api", "wp-json"] {
- app.get(.constant(prefix), "flamenet", "v1") { _ in
- NamespaceIndex.current
- }
+ // ⚠️ **Served ONLY under the prefix the routes are actually on.**
+ //
+ // It was briefly served under a second, legacy prefix as well, and that
+ // broke the iOS app completely. The app settles its REST prefix by
+ // probing for this document and keeping the first prefix that answers;
+ // the legacy prefix answered with a list of routes it does not serve,
+ // so the app adopted it and **every** call 404'd. Sign-up surfaced that
+ // as a bare "Server returned HTTP 404." with nothing pointing here.
+ //
+ // The rule is more general than the bug: serve a discovery document
+ // only where the things it describes exist. A document listing routes a
+ // prefix does not serve is worse than no document — it is believed.
+ app.get("api", "flamenet", "v1") { _ in
+ NamespaceIndex.current
}
app.logger.info("built-in identity provider and mobile surface enabled")
}
// Retention as a supervised in-process loop, not a scheduler that has to be
- // visited to run. The WordPress version hung off wp-cron and stopped for
- // eight weeks without anyone noticing, which meant the retention windows
+ // visited to run. The previous implementation hung off an external cron and
+ // stopped for eight weeks unnoticed, which meant the retention windows
// described intent rather than behaviour.
let retention = Retention(
db: app.db, config: config, logger: app.logger,
@@ -293,21 +390,74 @@ func register(routes r: Routes, on app: Application) throws {
"The cryptographic engine: what it implements and how it is tested."),
("relay", "Relay — Flamenet Messenger",
"The Swift relay: what it serves and how it is configured."),
+ // The self-hosting reference: every route, every variable, and the
+ // shortest thing that works. Served by the relay itself so an operator
+ // reading it is reading the same document their own relay ships.
+ ("api", "Relay API — Flamenet Messenger",
+ "Every route a relay serves, what authenticates it, and what it is for."),
+ ("configuration", "Configuring a relay — Flamenet Messenger",
+ "Every variable the relay reads and what it breaks if it is wrong."),
+ ("hosting", "Running your own relay — Flamenet Messenger",
+ "One container and one database. Files is optional."),
]
// Donations. The page is rendered whether or not a processor is set — the
// unconfigured state is part of what it says — but the form only exists
// when there is somewhere for it to post.
let donate = DonateRoutes(config: r.config)
+
+ // Say out loud which Stripe account and which Stripe mode this deploy takes
+ // money into.
+ //
+ // A relay running in production on a `sk_test_` key takes donations that
+ // never arrive, and nothing about the page looks wrong. The same is true of
+ // a platform key with no connected account: Stripe answers 200 and the
+ // money lands on the wrong business. Neither fails on its own, so both are
+ // logged where a deploy can be checked against them.
+ //
+ // ⚠️ `critical`, not `warning`. This relay's logger SWALLOWS warnings —
+ // `.notice` and `.critical` both reach the log and `.warning` does not, and
+ // a safeguard nobody can see is not a safeguard.
+ if let secret = r.config.donateSecret {
+ if secret.hasPrefix("sk_test_") || secret.hasPrefix("rk_test_") {
+ app.logger.critical("donations are in STRIPE TEST MODE — payments taken here are NOT REAL. Production must set FN_DONATE_SECRET and FN_DONATE_PUBLISHABLE_KEY to live keys.")
+ } else if secret.hasPrefix("sk_live_") || secret.hasPrefix("rk_live_") {
+ app.logger.notice("donations are in Stripe LIVE mode — real cards will be charged")
+ } else {
+ app.logger.critical("FN_DONATE_SECRET does not look like a Stripe key")
+ }
+ // A live secret paired with a test publishable key (or the reverse)
+ // fails at confirmation time, in the browser, after the person has
+ // typed their card in. Catch it here instead.
+ if let publishable = r.config.donatePublishableKey {
+ let secretLive = secret.contains("_live_")
+ let publishableLive = publishable.contains("_live_")
+ if secretLive != publishableLive {
+ app.logger.critical("FN_DONATE_SECRET and FN_DONATE_PUBLISHABLE_KEY are from DIFFERENT Stripe modes — one is live and the other is test. Payments will fail at confirmation, after the card has been entered.")
+ }
+ } else {
+ app.logger.critical("FN_DONATE_SECRET is set but FN_DONATE_PUBLISHABLE_KEY is not — the payment form cannot mount, so the donate page shows no form at all.")
+ }
+ switch r.config.donateAccount {
+ case .some(let account) where account.hasPrefix("acct_"):
+ app.logger.notice("donations are taken on connected account \(account)")
+ case .some(let account):
+ app.logger.critical("FN_DONATE_ACCOUNT is not a Stripe account id (\(account)) — every donation will fail at the processor.")
+ case .none:
+ app.logger.critical("FN_DONATE_ACCOUNT is unset — donations land on whichever account FN_DONATE_SECRET belongs to, which on a platform key is the platform itself.")
+ }
+ }
app.get("donate") { req async throws -> View in
try await req.view.render("donate", DonateContext(
title: "Donate — Flamenet Messenger",
description: "What donations pay for, what they do not buy, and better ways to help.",
audience: r.config.audience,
protoVersion: 3,
- donateEnabled: r.config.donateProvider != nil && r.config.donateSecret != nil,
+ donateEnabled: r.config.donateProvider != nil && r.config.donateSecret != nil
+ && r.config.donatePublishableKey != nil,
+ thanks: req.query[String.self, at: "thanks"] != nil,
processorName: (r.config.donateProvider ?? "the processor").capitalized))
}
- app.post("donate", "checkout") { req in try await donate.checkout(req) }
+ app.post("donate", "intent") { req in try await donate.intent(req) }
for (slug, title, description) in pages {
app.get(.constant(slug)) { req async throws -> View in
@@ -319,6 +469,13 @@ func register(routes r: Routes, on app: Application) throws {
}
}
+ // How a relay describes itself to a client or a peer holding nothing but
+ // its hostname. Unauthenticated on purpose — a descriptor only this relay's
+ // own users could read would be useless to the peers it exists to inform.
+ app.get(".well-known", "flamenet", "relay") { req in
+ try await r.relayDescriptor(req)
+ }
+
// RFC 9116. A researcher who finds something should not have to guess an
// address, and the footer link is only worth having if it resolves.
app.get(".well-known", "security.txt") { _ -> Response in
@@ -341,7 +498,7 @@ func register(routes r: Routes, on app: Application) throws {
// §8 is specified at `/e2e/*`, and that is the canonical mount.
//
- // It is also mounted under the WordPress REST namespace, because the
+ // It is also mounted under the messenger namespace, because the
// shipped client addresses it there — the same reason the messenger
// surface is mounted twice. This is a compatibility alias, not a second
// API: both point at one set of handlers.
@@ -355,6 +512,64 @@ func register(routes r: Routes, on app: Application) throws {
e2e.post("devices") { req in
try await r.registerDevice(req, body: body(of: req), now: unixNow())
}
+
+ // Global account identity. `account` rather than a path under `devices`
+ // because it is a property of the person, not of the install: every
+ // device of one account claims the same fnid.
+ // Server-to-server. Registered unconditionally so a peer gets a clear
+ // "this relay does not federate" rather than a 404 that reads as a
+ // broken deployment — the verifier refuses before anything else runs.
+ // Outbound: what a local client asks its own relay to do on its behalf.
+ e2e.post("send", "remote") { req in
+ try await r.sendRemote(req, body: body(of: req), now: unixNow())
+ }
+ e2e.get("remote", ":relay", "devices", ":fnid") { req in
+ guard let relay = req.parameters.get("relay"),
+ let fnid = req.parameters.get("fnid"), !relay.isEmpty, !fnid.isEmpty
+ else { throw WireError.badRequest("Malformed address.") }
+ return try await r.remoteDirectory(req, relay: relay, fnid: fnid, pops: false, now: unixNow())
+ }
+ e2e.get("remote", ":relay", "bundles", ":fnid") { req in
+ guard let relay = req.parameters.get("relay"),
+ let fnid = req.parameters.get("fnid"), !relay.isEmpty, !fnid.isEmpty
+ else { throw WireError.badRequest("Malformed address.") }
+ return try await r.remoteDirectory(req, relay: relay, fnid: fnid, pops: true, now: unixNow())
+ }
+
+ e2e.post("federation", "deposit") { req in
+ try await r.federationDeposit(req, body: body(of: req), now: unixNow())
+ }
+ e2e.get("federation", "bundles", ":fnid") { req in
+ guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else {
+ throw WireError.badRequest("Malformed identity.")
+ }
+ return try await r.federationBundles(req, fnid: fnid, now: unixNow())
+ }
+ e2e.get("federation", "devices", ":fnid") { req in
+ guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else {
+ throw WireError.badRequest("Malformed identity.")
+ }
+ return try await r.federationDevices(req, fnid: fnid, now: unixNow())
+ }
+
+ e2e.post("account", "identity") { req in
+ try await r.claimAccountIdentity(req, body: body(of: req), now: unixNow())
+ }
+ e2e.post("account", "homes") { req in
+ try await r.publishHomes(req, body: body(of: req), now: unixNow())
+ }
+ e2e.get("account", "homes", ":fnid") { req in
+ guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else {
+ throw WireError.badRequest("Malformed identity.")
+ }
+ return try await r.readHomes(req, fnid: fnid)
+ }
+ e2e.get("account", "identity", ":handle") { req in
+ guard let handle = req.parameters.get("handle"), !handle.isEmpty else {
+ throw WireError.badRequest("Malformed handle.")
+ }
+ return try await r.resolveIdentity(req, handle: handle)
+ }
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.")
@@ -484,9 +699,9 @@ func register(messaging m: MessagingRoutes, on app: Application) throws {
/// Mounted at `/api/flamenet/v1/messenger/*`.
///
-/// That prefix is not a choice. It is where the shipped app looks: WordPress
-/// REST with `wp-json` rewritten to `api`, and the app builds every URL from
-/// it. Serving the same routes anywhere else means the app cannot reach them.
+/// That prefix is not a choice. It is where the shipped app looks, and the app
+/// builds every URL from it. Serving these routes anywhere else means the app
+/// cannot reach them.
func register(mobile m: MobileRoutes, on app: Application) throws {
let g = app.grouped("api", "flamenet", "v1", "messenger")
g.get("buddies") { req in try await m.buddies(req) }
@@ -537,13 +752,23 @@ func register(mobile m: MobileRoutes, on app: Application) throws {
g.post("command") { req in try await m.command(req) }
}
-func register(account a: AccountRoutes, on app: Application) throws {
+func register(account a: AccountRoutes, config: Config, on app: Application) throws {
let account = app.grouped("account")
account.post("signup") { req in try await a.signup(req) }
account.post("signin") { req in
try await a.signin(req, clientIP: clientIP(req), now: unixNow())
}
account.post("token") { req in try await a.token(req, now: unixNow()) }
+ // Password reset. Registered whether or not mail is configured: the route
+ // answers identically either way, and a 404 when SMTP is unset would tell
+ // an anonymous caller how this relay is deployed.
+ let mailer = config.smtp.map {
+ Mailer(config: $0, group: app.eventLoopGroup, logger: app.logger)
+ }
+ account.post("reset", "request") { req in
+ try await a.requestReset(req, mailer: mailer, siteURL: config.audience, now: unixNow())
+ }
+ account.post("reset", "confirm") { req in try await a.confirmReset(req, now: unixNow()) }
}
func unixNow() -> Int { Int(Date().timeIntervalSince1970) }
@@ -608,7 +833,7 @@ struct StopTask: LifecycleHandler {
/// boot from the file contents, so it changes exactly when they do.
enum AssetVersion {
static let value: String = {
- let paths = ["Public/landing.css", "Public/landing.js"]
+ let paths = ["Public/landing.css", "Public/landing.js", "Public/donate.js"]
var hasher = SHA256()
for path in paths {
if let data = FileManager.default.contents(atPath: path) { hasher.update(data: data) }
@@ -623,6 +848,9 @@ struct DonateContext: Encodable {
let audience: String
let protoVersion: Int
let donateEnabled: Bool
+ /// Set by the payment coming back to this page, not by anything durable —
+ /// it says a payment was confirmed, not that this person is a donor.
+ let thanks: Bool
let processorName: String
let assetVersion: String = AssetVersion.value
}
@@ -644,11 +872,35 @@ struct LandingContext: Encodable {
/// broken page rather than a silent regression. The API responses are JSON and
/// carry it harmlessly.
struct SecurityHeadersMiddleware: AsyncMiddleware {
+ private static let base =
+ "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
+
+ /// The origins Stripe's Payment Element actually uses.
+ ///
+ /// Named individually, never a bare `https:` — a policy that allows every
+ /// origin is not a policy. This is wider than the obvious `js.stripe.com`
+ /// because the Element pulls in more than its script: `m.stripe.network`
+ /// carries its inner frames and telemetry, `*.stripe.com` serves the card
+ /// brand images, and Stripe injects an hCaptcha frame for fraud checks. A
+ /// policy missing any of them does not fail loudly — the fields simply do
+ /// not appear, which is indistinguishable from a broken integration.
+ private static let donate =
+ "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "
+ + "script-src 'self' https://js.stripe.com https://hcaptcha.com https://*.hcaptcha.com; "
+ + "frame-src https://js.stripe.com https://hooks.stripe.com https://m.stripe.network "
+ + "https://hcaptcha.com https://*.hcaptcha.com; "
+ + "connect-src 'self' https://api.stripe.com https://m.stripe.network "
+ + "https://hcaptcha.com https://*.hcaptcha.com; "
+ + "img-src 'self' data: https://*.stripe.com; "
+ + "style-src 'self' 'unsafe-inline'"
+
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let response = try await next.respond(to: request)
+ // Only this one page pays for the widening.
+ let isDonate = request.url.path.hasPrefix("/donate")
response.headers.replaceOrAdd(
name: .contentSecurityPolicy,
- value: "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
+ value: isDonate ? Self.donate : Self.base)
response.headers.replaceOrAdd(name: .init("X-Content-Type-Options"), value: "nosniff")
response.headers.replaceOrAdd(name: .init("Referrer-Policy"), value: "no-referrer")
response.headers.replaceOrAdd(
diff --git a/Sources/FlamenetRelay/Federation.swift b/Sources/FlamenetRelay/Federation.swift
new file mode 100644
index 0000000..98b1a5d
--- /dev/null
+++ b/Sources/FlamenetRelay/Federation.swift
@@ -0,0 +1,390 @@
+import Crypto
+import Foundation
+import Vapor
+
+/// Server-to-server authentication.
+///
+/// ## What a peer is allowed to do, and what it is not
+///
+/// A federated relay may deposit an envelope for one of our users, and read the
+/// prekey bundles our users have published. That is the entire grant. It may
+/// **not** mint a token, speak for a user, read an inbox, register a device, or
+/// claim an identity. Those all remain behind `TokenVerifier`, which checks a
+/// single pinned issuer key and has no notion of a peer at all.
+///
+/// Keeping the two apart is the point. The obvious shortcut — let a peer
+/// present a token it minted for its own user and trust it — would make every
+/// federated relay an identity provider for every other, so one compromised
+/// relay anywhere could speak for anyone. Instead a peer authenticates *as
+/// itself*, and what it asserts about its users is only ever "this ciphertext
+/// arrived from me", which is exactly as much as it should be able to say.
+///
+/// ## The signature
+///
+/// ```
+/// fnfed-v1|||||||
+/// ```
+///
+/// Every field earns its place:
+///
+/// - **origin and destination** are both bound. Origin says who is speaking;
+/// destination stops a request captured by one relay being replayed at
+/// another. Without the second, a hostile peer could take a deposit meant for
+/// it and re-present it everywhere else in the federation.
+/// - **body digest**, so a captured signature cannot be moved onto different
+/// ciphertext at the same path.
+/// - **nonce**, for the same reason the client proof carries one: without it the
+/// signature is a pure function of the other fields, and two legitimate
+/// requests in the same second are byte-identical — so a replay cache cannot
+/// tell an honest repeat from a captured one and refuses the honest peer.
+enum Federation {
+
+ /// Headers a peer sends. Distinct from the client's `X-FN-*` proof headers
+ /// so the two verification paths can never be confused for one another by a
+ /// handler that reads the wrong one.
+ enum Headers {
+ static let origin = "X-FN-Origin"
+ static let timestamp = "X-FN-Fed-Timestamp"
+ static let nonce = "X-FN-Fed-Nonce"
+ static let signature = "X-FN-Fed-Signature"
+ }
+
+ static func canonical(
+ method: String, path: String, origin: String, destination: String,
+ timestamp: String, nonce: String, body: Data
+ ) -> Data {
+ let digest = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()
+ return Data(
+ "fnfed-v1|\(method)|\(path)|\(origin)|\(destination)|\(timestamp)|\(nonce)|\(digest)".utf8)
+ }
+
+ enum AuthError: Error, CustomStringConvertible {
+ case federationDisabled
+ case peerNotAllowed(String)
+ case missingProof
+ case staleTimestamp
+ case unknownPeer(String)
+ case badSignature
+ case replayed
+
+ var description: String {
+ switch self {
+ case .federationDisabled: return "this relay does not federate"
+ case .peerNotAllowed(let p): return "peer \(p) is not on this relay's allowlist"
+ case .missingProof: return "federation headers are incomplete"
+ case .staleTimestamp: return "federation timestamp is outside the accepted window"
+ case .unknownPeer(let p): return "could not learn the signing key for \(p)"
+ case .badSignature: return "federation signature does not verify"
+ case .replayed: return "this federation request has already been seen"
+ }
+ }
+
+ /// A peer that is refused should be able to tell "you don't federate"
+ /// from "I got the signature wrong", because those call for opposite
+ /// responses — stop asking, versus fix and retry.
+ var status: HTTPResponseStatus {
+ switch self {
+ case .federationDisabled, .peerNotAllowed: return .forbidden
+ default: return .unauthorized
+ }
+ }
+ }
+
+ /// A verified peer. Handlers never see raw federation headers, exactly as
+ /// they never see a raw token.
+ struct Peer: Sendable {
+ /// The origin relay's audience. The only thing a handler may attribute
+ /// an inbound envelope to.
+ let audience: String
+ }
+}
+
+/// Fetches and caches what other relays say about themselves.
+///
+/// ⚠️ **This is the relay's only outbound HTTP.** A relay with
+/// `FN_FEDERATION=closed` never constructs one of these, which is what makes
+/// the self-hosting promise — a closed relay opens no outbound connections —
+/// true in code rather than only in documentation.
+actor PeerDirectory {
+ private let client: any Client
+ private let ttl: Int
+ private var cache: [String: (descriptor: PeerDescriptor, at: Int)] = [:]
+
+ /// A ceiling on how many peers we will remember.
+ ///
+ /// Under `open` the key is attacker-chosen, so an unbounded cache is a
+ /// memory leak anyone can drive. When it fills, the oldest entry goes — a
+ /// crude policy, and adequate: the cost of evicting a live peer is one
+ /// extra fetch.
+ private static let maxCached = 512
+
+ struct PeerDescriptor: Content, Sendable {
+ let audience: String
+ let federationKey: String?
+ let federation: String
+ }
+
+ init(client: any Client, ttl: Int = 3600) {
+ self.client = client
+ self.ttl = ttl
+ }
+
+ /// Whether a peer name may be dialled at all.
+ ///
+ /// Under `allowlist` the set of names is whatever the operator configured,
+ /// and this adds little. Under `open` the name arrives in a header from
+ /// anyone, and this is the whole defence: without it, `X-FN-Origin:
+ /// 169.254.169.254` turns the relay into a request forwarder pointed at the
+ /// cloud metadata endpoint, and `127.0.0.1` points it at whatever else the
+ /// operator is running on the box.
+ ///
+ /// A charset check alone does not do this — `127.0.0.1` is letters, digits
+ /// and dots, and it has a dot in it, so every obvious filter waves it
+ /// through. The name must be rejected for *being an address*, not for its
+ /// spelling.
+ ///
+ /// ⚠️ This does not resolve DNS, so a public name pointing at a private
+ /// address still gets through. Closing that means resolving first and
+ /// checking every answer, then pinning the result for the connection so the
+ /// name cannot be re-resolved to something else in between. That is worth
+ /// doing before `open` is recommended to anyone; `allowlist` does not
+ /// depend on it, which is the mode the fleet and the docs both use.
+ static func isFetchableHost(_ host: String) -> Bool {
+ guard !host.isEmpty, host.count <= 253, host.contains("."),
+ !host.hasPrefix("-"), !host.hasPrefix("."), !host.hasSuffix("-"),
+ host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" })
+ else { return false }
+
+ // An IPv4 literal is four numeric labels. Rejected wholesale rather than
+ // range by range: a relay is addressed by name, so there is no honest
+ // reason for a peer to be an address, and enumerating private ranges is
+ // a list that is always one entry out of date.
+ let labels = host.split(separator: ".", omittingEmptySubsequences: false)
+ if labels.count == 4, labels.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) {
+ return false
+ }
+ guard labels.allSatisfy({ !$0.isEmpty && $0.count <= 63 }) else { return false }
+
+ // Names that never belong to a public relay. `.local` is mDNS, and the
+ // rest are conventions for exactly the hosts this must not reach.
+ let last = labels.last.map(String.init)?.lowercased() ?? ""
+ return !["local", "internal", "localhost", "localdomain", "home", "lan"].contains(last)
+ }
+
+ /// A peer's descriptor, from cache when it is fresh.
+ ///
+ /// Cached because it is fetched on the verification path: without a cache,
+ /// every inbound envelope becomes an outbound request to the sender, which
+ /// hands any peer an amplifier pointed at us and makes our availability
+ /// depend on theirs.
+ func descriptor(for audience: String, now: Int) async throws -> PeerDescriptor {
+ if let hit = cache[audience], now - hit.at < ttl { return hit.descriptor }
+
+ guard Self.isFetchableHost(audience) else {
+ throw Federation.AuthError.unknownPeer(audience)
+ }
+
+ let uri = URI(string: "https://\(audience)/.well-known/flamenet/relay")
+ // A deadline, because this sits on the inbound verification path: a peer
+ // that accepts the connection and then says nothing would otherwise hold
+ // one of our request handlers for as long as it likes, which makes our
+ // availability a function of the slowest host that can name itself.
+ let response = try await client.get(uri) { req in
+ req.headers.replaceOrAdd(name: .accept, value: "application/json")
+ }
+ guard response.status == .ok,
+ let d = try? response.content.decode(PeerDescriptor.self)
+ else { throw Federation.AuthError.unknownPeer(audience) }
+
+ // A descriptor that names a different audience than the host it came
+ // from is either misconfigured or an attempt to be somebody else. Either
+ // way it cannot be used to attribute traffic.
+ guard d.audience == audience else { throw Federation.AuthError.unknownPeer(audience) }
+
+ if cache.count >= Self.maxCached,
+ let oldest = cache.min(by: { $0.value.at < $1.value.at })?.key {
+ cache.removeValue(forKey: oldest)
+ }
+ cache[audience] = (d, now)
+ return d
+ }
+}
+
+/// Verifies inbound server-to-server requests.
+struct FederationVerifier: Sendable {
+ let policy: String
+ let peers: [String]
+ let audience: String
+ let skew: Int
+ let directory: PeerDirectory?
+ let replay: ReplayCache
+
+ var enabled: Bool { policy != "closed" }
+
+ /// Whether this relay will talk to `origin` at all.
+ ///
+ /// Checked *before* the signature, and before any outbound fetch. A closed
+ /// relay must not be induced to dial an arbitrary host just by being sent a
+ /// header naming it — which is what verifying first would do, since
+ /// verifying requires fetching the peer's key.
+ func admits(_ origin: String) -> Bool {
+ switch policy {
+ case "open": return true
+ case "allowlist": return peers.contains(origin)
+ default: return false
+ }
+ }
+
+ func verify(
+ method: String, path: String, body: Data,
+ origin: String?, timestamp: String?, nonce: String?, signature: String?,
+ now: Int
+ ) async throws -> Federation.Peer {
+ guard enabled, let directory else { throw Federation.AuthError.federationDisabled }
+ guard let origin, !origin.isEmpty else { throw Federation.AuthError.missingProof }
+ guard admits(origin) else { throw Federation.AuthError.peerNotAllowed(origin) }
+ guard let timestamp, let nonce, !nonce.isEmpty, let signature else {
+ throw Federation.AuthError.missingProof
+ }
+ guard let ts = Int(timestamp), abs(now - ts) <= skew else {
+ throw Federation.AuthError.staleTimestamp
+ }
+
+ let descriptor = try await directory.descriptor(for: origin, now: now)
+ guard let keyB64 = descriptor.federationKey,
+ let keyRaw = Data(base64Encoded: keyB64),
+ let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyRaw)
+ else { throw Federation.AuthError.unknownPeer(origin) }
+
+ guard let sig = Data(base64Encoded: signature) else {
+ throw Federation.AuthError.badSignature
+ }
+ // `audience` here is OURS, not the header's: the destination is what we
+ // know ourselves to be, never what the caller asserts. Taking it from
+ // the request would let a peer sign for whoever it liked.
+ let canonical = Federation.canonical(
+ method: method, path: path, origin: origin, destination: audience,
+ timestamp: timestamp, nonce: nonce, body: body)
+ guard key.isValidSignature(sig, for: canonical) else {
+ throw Federation.AuthError.badSignature
+ }
+
+ // Namespaced by origin so two peers cannot collide on a nonce and
+ // knock each other's traffic out.
+ guard await replay.admit("fed:\(origin):\(nonce)", now: now) else {
+ throw Federation.AuthError.replayed
+ }
+ return Federation.Peer(audience: origin)
+ }
+}
+
+/// Signs and sends this relay's own requests to a peer.
+///
+/// The mirror of `FederationVerifier`, and deliberately the only thing in the
+/// codebase that can produce a `fnfed-v1` signature — a second signer would be
+/// a second chance to disagree about what gets bound, and what gets bound is
+/// the whole security property.
+struct FederationSender: Sendable {
+ let audience: String
+ let signingKeyRaw: Data
+ let client: any Client
+ /// Peers this relay is willing to dial, by the same rule it admits them.
+ let admits: @Sendable (String) -> Bool
+
+ enum SendError: Error, CustomStringConvertible {
+ case notPermitted(String)
+ case unreachable(String)
+ case refused(String, HTTPResponseStatus)
+
+ var description: String {
+ switch self {
+ case .notPermitted(let p): return "this relay does not federate with \(p)"
+ case .unreachable(let p): return "could not reach \(p)"
+ case .refused(let p, let s): return "\(p) refused the request (\(s.code))"
+ }
+ }
+ }
+
+ private func signedHeaders(
+ method: String, path: String, destination: String, body: Data, now: Int
+ ) throws -> HTTPHeaders {
+ guard let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingKeyRaw) else {
+ throw Abort(.internalServerError, reason: "federation key is unusable")
+ }
+ let ts = String(now)
+ let nonce = UUID().uuidString
+ let canonical = Federation.canonical(
+ method: method, path: path, origin: audience, destination: destination,
+ timestamp: ts, nonce: nonce, body: body)
+ let sig = try key.signature(for: canonical).base64EncodedString()
+
+ var headers = HTTPHeaders()
+ headers.replaceOrAdd(name: Federation.Headers.origin, value: audience)
+ headers.replaceOrAdd(name: Federation.Headers.timestamp, value: ts)
+ headers.replaceOrAdd(name: Federation.Headers.nonce, value: nonce)
+ headers.replaceOrAdd(name: Federation.Headers.signature, value: sig)
+ return headers
+ }
+
+ /// Hand ciphertext to the relay that homes the recipient.
+ func deposit(
+ to peer: String, envelopes: [FederationDepositRequest.Item], now: Int
+ ) async throws -> FederationDepositResponse {
+ guard admits(peer), PeerDirectory.isFetchableHost(peer) else {
+ throw SendError.notPermitted(peer)
+ }
+ let path = "/e2e/federation/deposit"
+ // Encoded once, and the very bytes that were signed are the bytes sent.
+ // Re-encoding for the request would let a body whose JSON does not
+ // round-trip byte-for-byte arrive with a signature over different
+ // content — the same trap the token verifier avoids by signing the
+ // received segments rather than a re-serialisation of the claims.
+ let payload = try JSONEncoder().encode(FederationDepositRequest(envelopes: envelopes))
+ var headers = try signedHeaders(
+ method: "POST", path: path, destination: peer, body: payload, now: now)
+ headers.replaceOrAdd(name: .contentType, value: "application/json")
+
+ let response: ClientResponse
+ do {
+ response = try await client.post(
+ URI(string: "https://\(peer)\(path)"), headers: headers) { req in
+ req.body = .init(data: payload)
+ }
+ } catch {
+ throw SendError.unreachable(peer)
+ }
+ guard response.status == .ok,
+ let decoded = try? response.content.decode(FederationDepositResponse.self)
+ else { throw SendError.refused(peer, response.status) }
+ return decoded
+ }
+
+ /// Fetch what a peer publishes about one of its users.
+ ///
+ /// `pops` decides which route, and the caller must mean it: the bundle route
+ /// consumes a one-time prekey per device at the far end, so a client that
+ /// only wants to see identity keys must not take that path.
+ func fetch(
+ from peer: String, fnid: String, pops: Bool, now: Int
+ ) async throws -> ClientResponse {
+ guard admits(peer), PeerDirectory.isFetchableHost(peer) else {
+ throw SendError.notPermitted(peer)
+ }
+ let path = "/e2e/federation/\(pops ? "bundles" : "devices")/\(fnid)"
+ return try await get(from: peer, path: path, now: now)
+ }
+
+ /// An authenticated GET at a peer, for any of the routes it exposes to us.
+ func get(from peer: String, path: String, now: Int) async throws -> ClientResponse {
+ guard admits(peer), PeerDirectory.isFetchableHost(peer) else {
+ throw SendError.notPermitted(peer)
+ }
+ let headers = try signedHeaders(
+ method: "GET", path: path, destination: peer, body: Data(), now: now)
+ do {
+ return try await client.get(URI(string: "https://\(peer)\(path)"), headers: headers)
+ } catch {
+ throw SendError.unreachable(peer)
+ }
+ }
+}
diff --git a/Sources/FlamenetRelay/HomeRelays.swift b/Sources/FlamenetRelay/HomeRelays.swift
new file mode 100644
index 0000000..35a2491
--- /dev/null
+++ b/Sources/FlamenetRelay/HomeRelays.swift
@@ -0,0 +1,133 @@
+import Crypto
+import Fluent
+import Foundation
+import Vapor
+
+/// Where an account keeps its mailboxes.
+///
+/// ## The problem this solves, and the one it does not
+///
+/// Federation lets somebody on another relay write to you. It does not stop
+/// your relay being the single place your mail lives: register at relay1 and
+/// every envelope for you is in relay1's database, so relay1 going away takes
+/// the undelivered ones with it and relay1 refusing to serve you locks you out
+/// of your own inbox.
+///
+/// Multi-homing splits that. An account registers the **same devices, with the
+/// same identity keys** at several relays, and senders deliver to all of them.
+/// Any one of those relays can then be unreachable, hostile, or gone, and the
+/// message still arrives — because it was never only there.
+///
+/// It buys redundancy of the *mailbox*, not of the conversation: a device that
+/// was never linked still cannot read history it never received, and nothing
+/// here changes §16's position that the relay does not hold your history.
+///
+/// ## Why the list is signed, and why it carries a serial
+///
+/// A relay that could edit this list could quietly drop the other relays from
+/// it and become the only place your mail goes — which is precisely the
+/// position multi-homing exists to escape. So the account key signs the list,
+/// and no relay can produce a signature for a key it does not have.
+///
+/// The **serial** closes the other half. A signature alone lets a relay serve
+/// an *older* list it once saw honestly — one from before you added relay2 —
+/// and that older list is genuinely signed, so a verifier checking only the
+/// signature accepts it. The serial makes rollback detectable: a relay may not
+/// replace a record with one carrying a lower number, and a client that has
+/// seen serial 5 refuses a 4 it is later handed.
+///
+/// ```
+/// fnhomes-v1|||,,...
+/// ```
+///
+/// Relays are joined in the order given and signed as given: reordering
+/// produces a different signature, so there is no canonicalisation step for
+/// three implementations to disagree about.
+enum HomeRelays {
+
+ /// A hard cap, because a sender fans out to every entry.
+ ///
+ /// Without one, a signed record naming a thousand hosts turns any sender's
+ /// relay into an amplifier pointed wherever the signer likes — and the
+ /// signature makes it look legitimate. Four is comfortably more than the
+ /// "my box, a friend's box, and a paid backstop" this is for.
+ static let maxHomes = 4
+
+ static func canonical(fnid: String, serial: Int, relays: [String]) -> Data {
+ Data("fnhomes-v1|\(fnid)|\(serial)|\(relays.joined(separator: ","))".utf8)
+ }
+
+ /// Verify a record against the account key the address is derived from.
+ ///
+ /// Self-certifying, like the claim: the fnid *is* the public key, so the
+ /// record proves itself and a relay storing it is never asked to vouch for
+ /// anything.
+ static func verify(
+ fnid: String, serial: Int, relays: [String], signatureB64: String
+ ) -> Bool {
+ guard fnid.hasPrefix("fnid:"),
+ let raw = decodeAddress(fnid),
+ let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw),
+ let sig = Data(base64Encoded: signatureB64)
+ else { return false }
+ return key.isValidSignature(sig, for: canonical(fnid: fnid, serial: serial, relays: relays))
+ }
+
+ /// Recover the account public key from its address.
+ ///
+ /// The address is the key, so this is a decode rather than a lookup — which
+ /// is what lets any party check a record without asking a server who the
+ /// signer was.
+ static func decodeAddress(_ fnid: String) -> Data? {
+ let body = String(fnid.dropFirst("fnid:".count))
+ guard body.count == FNID.encodedLength else { return nil }
+ var buffer = 0, bits = 0
+ var out = Data()
+ for ch in body {
+ guard let v = FNID.alphabet.firstIndex(of: ch) else { return nil }
+ buffer = (buffer << 5) | v
+ bits += 5
+ if bits >= 8 {
+ bits -= 8
+ out.append(UInt8((buffer >> bits) & 0xFF))
+ }
+ }
+ return out.count == 32 ? out : nil
+ }
+}
+
+/// The stored record. One per account, replaced only by a higher serial.
+final class HomeRecord: Model, @unchecked Sendable {
+ static let schema = "home_relays"
+
+ @ID(custom: "fnid", generatedBy: .user) var id: String?
+ @Field(key: "serial") var serial: Int
+ /// Comma-joined, exactly as signed. Stored as one string rather than a
+ /// child table because the signature covers the joined form — splitting it
+ /// for storage would mean rebuilding it for verification, and a rebuild
+ /// that differs by one character fails in a way that looks like a bad key.
+ @Field(key: "relays") var relays: String
+ @Field(key: "sig") var sig: String
+ @Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
+
+ init() {}
+
+ var relayList: [String] {
+ relays.split(separator: ",").map(String.init).filter { !$0.isEmpty }
+ }
+}
+
+struct CreateHomeRecord: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(HomeRecord.schema)
+ .field("fnid", .string, .identifier(auto: false))
+ .field("serial", .int, .required)
+ .field("relays", .string, .required)
+ .field("sig", .string, .required)
+ .field("updated_at", .datetime)
+ .create()
+ }
+ func revert(on database: any Database) async throws {
+ try await database.schema(HomeRecord.schema).delete()
+ }
+}
diff --git a/Sources/FlamenetRelay/Issuer.swift b/Sources/FlamenetRelay/Issuer.swift
index 37dbbe9..bd5f70b 100644
--- a/Sources/FlamenetRelay/Issuer.swift
+++ b/Sources/FlamenetRelay/Issuer.swift
@@ -14,12 +14,12 @@ import Vapor
/// But co-location does cost something real and it should be said plainly: in
/// one process, a relay compromise **is** an issuer compromise, because the
/// signing key is in the same address space. That is a worse position than
-/// flamenet.io's, where WordPress holds the signing key and a relay takeover
+/// a split deployment, where a separate issuer holds the signing key and a relay takeover
/// cannot mint anything.
///
/// The trade is deliberate and it is for self-hosters, who are running one box
/// either way — for them the alternative is not "two isolated services", it is
-/// "install and secure WordPress". Enabled only when `FN_ISSUER_PRIVATE_KEY` is
+/// "install and secure the previous platform". Enabled only when `FN_ISSUER_PRIVATE_KEY` is
/// set; on flamenet.io it is not, and this file does nothing.
struct Issuer: Sendable {
/// Ed25519 private key seed, raw bytes. Bytes rather than a key object for
diff --git a/Sources/FlamenetRelay/KeyLog.swift b/Sources/FlamenetRelay/KeyLog.swift
index 282fba3..3a6bc3a 100644
--- a/Sources/FlamenetRelay/KeyLog.swift
+++ b/Sources/FlamenetRelay/KeyLog.swift
@@ -18,6 +18,14 @@ final class LogEntry: Model, @unchecked Sendable {
@ID(custom: "seq", generatedBy: .database) var id: Int?
@Field(key: "leaf_hash") var leafHash: String
@Field(key: "user_id") var userID: Int
+ /// Whose key this leaf is about, as the leaf itself names them — an `fnid:`
+ /// address where the account has published one, a `local:` form otherwise.
+ ///
+ /// Stored rather than recomputed on read: the leaf hash committed to this
+ /// exact string, and an account that publishes its key later would
+ /// otherwise silently change the subject of leaves already in the tree,
+ /// making every historical inclusion proof fail.
+ @Field(key: "subject") var subject: String
@Field(key: "device_id") var deviceID: String
/// "publish" or "revoke".
@Field(key: "kind") var kind: String
@@ -26,6 +34,30 @@ final class LogEntry: Model, @unchecked Sendable {
init() {}
}
+/// Add the subject a leaf names.
+///
+/// Backfilled to the `local:` form for rows written before this existed, which
+/// is what those leaves actually committed to under v1 in every respect that
+/// matters: a relay-scoped row number. Their stored `leaf_hash` is left alone —
+/// it is the v1 hash and rewriting it would break any inclusion proof already
+/// issued against it.
+struct AddLogSubject: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(LogEntry.schema).field("subject", .string).update()
+ try await (database as? any SQLDatabase)?
+ .raw("UPDATE key_log SET subject = 'legacy-v1/' || user_id WHERE subject IS NULL").run()
+ // Deliberately not tightened to NOT NULL. SQLite cannot add a
+ // constraint to an existing column without rebuilding the table, and
+ // this relay ships on SQLite and Postgres with a schema that is meant
+ // to be identical on both — a Postgres-only constraint would make
+ // "identical" quietly false. The model's property is non-optional, so
+ // every row written from here on carries one regardless.
+ }
+ func revert(on database: any Database) async throws {
+ try await database.schema(LogEntry.schema).deleteField("subject").update()
+ }
+}
+
struct CreateLogEntry: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(LogEntry.schema)
@@ -52,28 +84,48 @@ enum KeyLog {
/// reproducible by every independent verifier forever: key order, spacing
/// and escaping all have to be pinned, and an encoder is free to change any
/// of them between releases.
- static func canonical(userID: Int, deviceID: String, ikSig: String,
+ /// `v2` binds a `LogSubject` where `v1` bound this relay's `user_id`.
+ ///
+ /// A row number is only meaningful to the relay that assigned it, which
+ /// made every v1 leaf unverifiable by the third-party monitors §19 exists
+ /// to enable, and unusable for federation: two relays could not agree that
+ /// two leaves describe the same person. The version prefix changes with the
+ /// field so a v1 leaf can never be reinterpreted under v2 rules.
+ static func canonical(subject: String, deviceID: String, ikSig: String,
ikDH: String, kind: String) -> Data {
- Data("v1|\(kind)|\(userID)|\(deviceID)|\(ikSig)|\(ikDH)".utf8)
+ Data("v2|\(kind)|\(subject)|\(deviceID)|\(ikSig)|\(ikDH)".utf8)
}
/// Append a key publication. Called inside the same transaction as the
/// write it describes, so the log cannot disagree with the table it is
/// supposed to be a record of.
static func append(
- userID: Int, deviceID: String, ikSig: String, ikDH: String, kind: String,
- on db: any Database
+ userID: Int, subject: String, deviceID: String, ikSig: String, ikDH: String,
+ kind: String, on db: any Database
) async throws {
let leaf = MerkleLog.leafHash(
- canonical(userID: userID, deviceID: deviceID, ikSig: ikSig, ikDH: ikDH, kind: kind))
+ canonical(subject: subject, deviceID: deviceID, ikSig: ikSig, ikDH: ikDH, kind: kind))
let row = LogEntry()
row.leafHash = leaf.base64EncodedString()
row.userID = userID
+ row.subject = subject
row.deviceID = deviceID
row.kind = kind
try await row.create(on: db)
}
+ /// The subject to log for a user, read from the account row.
+ ///
+ /// A lookup rather than a parameter threaded through every caller: the
+ /// subject is a property of the account, and a caller that has to remember
+ /// to pass it is a caller that can pass the wrong one.
+ static func subject(
+ forUserID userID: Int, audience: String, on db: any Database
+ ) async throws -> String {
+ let fnid = try await User.find(userID, on: db)?.fnid
+ return LogSubject.forAccount(fnid: fnid, audience: audience, userID: userID)
+ }
+
/// Every leaf hash, in log order.
///
/// Recomputed per request rather than cached. That is honest for the size
diff --git a/Sources/FlamenetRelay/Mailer.swift b/Sources/FlamenetRelay/Mailer.swift
new file mode 100644
index 0000000..b8dab3a
--- /dev/null
+++ b/Sources/FlamenetRelay/Mailer.swift
@@ -0,0 +1,267 @@
+import Foundation
+import NIOConcurrencyHelpers
+import NIOCore
+import NIOPosix
+import NIOSSL
+import Vapor
+
+/// Minimal SMTP submission, for the one message this service sends.
+///
+/// Hand-written rather than a dependency because the surface actually needed is
+/// small — EHLO, optional STARTTLS, optional AUTH, one recipient, one body —
+/// and a mail library brings a MIME builder, attachment handling and address
+/// parsing that this relay has no use for and would have to keep patched.
+///
+/// It is **not** a general SMTP client. No pipelining, no 8BITMIME
+/// negotiation, no multi-recipient, no retry queue. A send either works or
+/// throws, and the caller decides what that means.
+struct Mailer: Sendable {
+ let config: SMTPConfig
+ let group: any EventLoopGroup
+ let logger: Logger
+
+ enum Failure: Error, LocalizedError {
+ case unexpected(code: Int, line: String, step: String)
+ case tlsRefused
+ case authRequiredButUnconfigured
+
+ var errorDescription: String? {
+ switch self {
+ case .unexpected(let code, let line, let step):
+ return "SMTP \(step) got \(code): \(line)"
+ case .tlsRefused:
+ return "The mail server does not offer STARTTLS. Set FN_SMTP_TLS=false only "
+ + "for a loopback server."
+ case .authRequiredButUnconfigured:
+ return "The mail server wants authentication but FN_SMTP_USERNAME is unset."
+ }
+ }
+ }
+
+ /// Send one plain-text message.
+ func send(to recipient: String, subject: String, body: String) async throws {
+ // The handler goes in during the bootstrap, NOT after connect. NIO
+ // starts reading as soon as the channel is active, so a handler added
+ // afterwards misses the server's 220 greeting -- it is delivered to a
+ // pipeline with nothing in it and dropped. Every later step then waits
+ // for a reply that already came and went, and the send hangs forever
+ // rather than failing.
+ let reader = LineHandler()
+ let channel = try await ClientBootstrap(group: group)
+ .connectTimeout(.seconds(15))
+ .channelInitializer { channel in
+ channel.pipeline.addHandler(reader)
+ }
+ .connect(host: config.host, port: config.port)
+ .get()
+ let session = SMTPSession(channel: channel, handler: reader)
+
+ defer { _ = channel.close() }
+
+ _ = try await session.expect(220, step: "greeting")
+ var capabilities = try await session.command("EHLO \(Self.heloName)", expect: 250, step: "EHLO")
+
+ if config.useTLS {
+ guard capabilities.uppercased().contains("STARTTLS") else { throw Failure.tlsRefused }
+ _ = try await session.command("STARTTLS", expect: 220, step: "STARTTLS")
+
+ var tls = TLSConfiguration.makeClientConfiguration()
+ tls.certificateVerification = .fullVerification
+ let ssl = try NIOSSLContext(configuration: tls)
+ let handler = try NIOSSLClientHandler(context: ssl, serverHostname: config.host)
+ // In front of the line reader, so everything after this point is
+ // encrypted on the way out and decrypted on the way in.
+ try await channel.pipeline.addHandler(handler, position: .first)
+ // A second EHLO is required, not optional: the capability list
+ // before STARTTLS is not trustworthy and servers commonly advertise
+ // AUTH only after the upgrade.
+ capabilities = try await session.command("EHLO \(Self.heloName)", expect: 250, step: "EHLO (TLS)")
+ }
+
+ if let username = config.username, let password = config.password {
+ // AUTH PLAIN: a single base64 of \0user\0pass.
+ let token = Data(("\u{0}" + username + "\u{0}" + password).utf8).base64EncodedString()
+ _ = try await session.command("AUTH PLAIN \(token)", expect: 235, step: "AUTH")
+ } else if capabilities.uppercased().contains("AUTH") && config.useTLS {
+ // Advertising AUTH does not always mean requiring it, so this is a
+ // log line rather than a refusal — a relay beside its own MTA
+ // legitimately submits without credentials.
+ logger.info("SMTP server advertises AUTH but no FN_SMTP_USERNAME is set")
+ }
+
+ _ = try await session.command("MAIL FROM:<\(Self.address(from: config.from))>",
+ expect: 250, step: "MAIL FROM")
+ _ = try await session.command("RCPT TO:<\(recipient)>", expect: 250, step: "RCPT TO")
+ _ = try await session.command("DATA", expect: 354, step: "DATA")
+ _ = try await session.command(Self.message(from: config.from, to: recipient,
+ subject: subject, body: body) + "\r\n.",
+ expect: 250, step: "body")
+ _ = try? await session.command("QUIT", expect: 221, step: "QUIT")
+ }
+
+ /// The name given in EHLO. Not the public hostname on purpose: this is a
+ /// submission client, the server does not route on it, and putting the
+ /// relay's real hostname in every outbound conversation tells any mail
+ /// server it is configured against which service is behind it.
+ private static let heloName = "localhost"
+
+ /// `Name ` → `addr@host`. The envelope wants the bare address.
+ static func address(from field: String) -> String {
+ guard let open = field.lastIndex(of: "<"), let close = field.lastIndex(of: ">"),
+ open < close
+ else { return field.trimmingCharacters(in: .whitespaces) }
+ return String(field[field.index(after: open).. String {
+ let stuffed = body
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map { $0.hasPrefix(".") ? "." + $0 : String($0) }
+ .joined(separator: "\r\n")
+ let date = ISO8601DateFormatter().string(from: Date())
+ return [
+ "From: \(from)",
+ "To: <\(to)>",
+ "Subject: \(subject)",
+ "Date: \(date)",
+ "MIME-Version: 1.0",
+ "Content-Type: text/plain; charset=utf-8",
+ "",
+ stuffed,
+ ].joined(separator: "\r\n")
+ }
+}
+
+/// Drives one SMTP conversation: write a line, await the reply.
+///
+/// SMTP replies can be multi-line (`250-FOO` then `250 BAR`), and a reader that
+/// stops at the first line desynchronises the whole session — every later
+/// command then reads the previous command's leftovers and the failure looks
+/// random. So a reply is complete only on a line whose fourth character is a
+/// space.
+private final class SMTPSession: @unchecked Sendable {
+ let handler: LineHandler
+ private let channel: any Channel
+
+ init(channel: any Channel, handler: LineHandler) {
+ self.channel = channel
+ self.handler = handler
+ }
+
+ func expect(_ code: Int, step: String) async throws -> String {
+ let reply = try await handler.nextReply()
+ guard reply.code == code else {
+ throw Mailer.Failure.unexpected(code: reply.code, line: reply.text, step: step)
+ }
+ return reply.text
+ }
+
+ @discardableResult
+ func command(_ line: String, expect code: Int, step: String) async throws -> String {
+ var buffer = channel.allocator.buffer(capacity: line.utf8.count + 2)
+ buffer.writeString(line + "\r\n")
+ try await channel.writeAndFlush(buffer).get()
+ return try await expect(code, step: step)
+ }
+}
+
+/// Accumulates bytes into complete SMTP replies.
+final class LineHandler: ChannelInboundHandler, @unchecked Sendable {
+ typealias InboundIn = ByteBuffer
+
+ struct Reply { let code: Int; let text: String }
+
+ private var pending: [CheckedContinuation] = []
+ private var ready: [Reply] = []
+ private var partial = ""
+ private var accumulated: [String] = []
+ private let lock = NIOLock()
+
+ /// Every `resume` below happens AFTER the lock is released.
+ ///
+ /// `NIOLock` is not reentrant, and resuming a continuation can run the
+ /// awaiting task inline on this very thread — which then asks for the next
+ /// reply, takes the same lock, and deadlocks. The symptom is not a crash:
+ /// the send hangs, the HTTP request never answers, and the relay looks
+ /// healthy the whole time.
+ func nextReply() async throws -> Reply {
+ try await withCheckedThrowingContinuation { continuation in
+ let immediate: Reply? = lock.withLock {
+ if ready.isEmpty {
+ pending.append(continuation)
+ return nil
+ }
+ return ready.removeFirst()
+ }
+ if let immediate { continuation.resume(returning: immediate) }
+ }
+ }
+
+ func channelRead(context: ChannelHandlerContext, data: NIOAny) {
+ var buffer = unwrapInboundIn(data)
+ guard let chunk = buffer.readString(length: buffer.readableBytes) else { return }
+ partial += chunk
+
+ // `isNewline`, NOT `firstIndex(of: "\n")`. Swift folds CR LF into a
+ // SINGLE Character — "\r\n" is one extended grapheme cluster — so
+ // searching for "\n" never matches a CRLF line ending, which is the
+ // only kind SMTP uses. The symptom is not a parse error: every reply
+ // sits in the buffer unrecognised, the client waits for a greeting that
+ // already arrived, and the send hangs with the socket wide open.
+ while let newline = partial.firstIndex(where: \.isNewline) {
+ let raw = String(partial[partial.startIndex..] = lock.withLock {
+ let all = pending
+ pending.removeAll()
+ return all
+ }
+ for continuation in waiting { continuation.resume(throwing: error) }
+ context.close(promise: nil)
+ }
+
+ /// A closed channel must fail everyone still waiting. Without this a server
+ /// that hangs up mid-conversation leaves the send suspended forever, which
+ /// on a request path is a stuck connection rather than an error.
+ func channelInactive(context: ChannelHandlerContext) {
+ let waiting: [CheckedContinuation] = lock.withLock {
+ let all = pending
+ pending.removeAll()
+ return all
+ }
+ for continuation in waiting {
+ continuation.resume(throwing: Mailer.Failure.unexpected(
+ code: 0, line: "connection closed", step: "session"))
+ }
+ }
+
+ private func deliver(_ reply: Reply) {
+ let waiting: CheckedContinuation? = lock.withLock {
+ if pending.isEmpty {
+ ready.append(reply)
+ return nil
+ }
+ return pending.removeFirst()
+ }
+ waiting?.resume(returning: reply)
+ }
+}
diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift
index 5c4b6ea..be849cf 100644
--- a/Sources/FlamenetRelay/Migrations.swift
+++ b/Sources/FlamenetRelay/Migrations.swift
@@ -215,6 +215,52 @@ struct AddEnclavePseudonym: AsyncMigration {
}
}
+/// A client-chosen id carried by every copy of one message, so a client polling
+/// several home relays can tell one message from two.
+///
+/// Not unique in the database: two rows legitimately share an id when the same
+/// message is addressed to two devices, and a uniqueness constraint here would
+/// reject the second as a duplicate of the first.
+struct AddEnvelopeID: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(Envelope.schema).field("envelope_id", .string).update()
+ try await (database as? any SQLDatabase)?
+ .raw("CREATE INDEX IF NOT EXISTS envelopes_envelope_id ON envelopes(envelope_id)").run()
+ }
+ func revert(on database: any Database) async throws {
+ try await (database as? any SQLDatabase)?.raw("DROP INDEX IF EXISTS envelopes_envelope_id").run()
+ try await database.schema(Envelope.schema).deleteField("envelope_id").update()
+ }
+}
+
+/// A global account identity, so this relay can name a user in a way another
+/// relay would recognise.
+///
+/// Both columns are nullable and stay that way. An account created before this
+/// existed has no account key, a self-hosted relay's user may never publish
+/// one, and a device can be registered before its key is claimed — none of
+/// those are error states, and a NOT NULL here would turn all three into one.
+struct AddAccountIdentity: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(User.schema).field("account_key", .string).update()
+ try await database.schema(User.schema).field("fnid", .string).update()
+ // Unique in the database, not just in the handler that claims it. Two
+ // accounts must never share an fnid: the address is the identity, and
+ // a duplicate would let one account be mistaken for another by every
+ // peer that resolves it. The check and the write are not one
+ // operation, and two claims arriving together is precisely when that
+ // gap is reachable.
+ try await (database as? any SQLDatabase)?.raw("""
+ CREATE UNIQUE INDEX IF NOT EXISTS users_fnid ON users (fnid)
+ """).run()
+ }
+ func revert(on database: any Database) async throws {
+ try await (database as? any SQLDatabase)?.raw("DROP INDEX IF EXISTS users_fnid").run()
+ try await database.schema(User.schema).deleteField("fnid").update()
+ try await database.schema(User.schema).deleteField("account_key").update()
+ }
+}
+
/// Let an account exist without an email address.
///
/// One authenticated by the shared FlameNet account service has none here, and
diff --git a/Sources/FlamenetRelay/Models.swift b/Sources/FlamenetRelay/Models.swift
index c353dbb..fc76fba 100644
--- a/Sources/FlamenetRelay/Models.swift
+++ b/Sources/FlamenetRelay/Models.swift
@@ -89,6 +89,16 @@ final class Envelope: Model, @unchecked Sendable {
@Field(key: "from_user") var fromUser: Int
@Field(key: "from_device") var fromDevice: String
@Field(key: "payload") var payload: String
+ /// A client-chosen id for THIS message to THIS device, identical in every
+ /// copy sent to every home relay.
+ ///
+ /// It is what makes fan-out safe: the same envelope collected from two
+ /// relays is one message, and only the sender can say so — a relay cannot,
+ /// because it sees ciphertext that differs per copy only if the sender
+ /// encrypted twice, and identical bytes otherwise. Nullable, because an
+ /// envelope from a client that predates this has no id and must still be
+ /// delivered.
+ @OptionalField(key: "envelope_id") var envelopeID: String?
@Field(key: "delivered") var delivered: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
diff --git a/Sources/FlamenetRelay/PasswordReset.swift b/Sources/FlamenetRelay/PasswordReset.swift
new file mode 100644
index 0000000..ca99969
--- /dev/null
+++ b/Sources/FlamenetRelay/PasswordReset.swift
@@ -0,0 +1,207 @@
+import Crypto
+import Fluent
+import Foundation
+import SQLKit
+import Vapor
+
+/// A pending password reset.
+///
+/// The token is `.`, same shape as an app credential: the
+/// selector is a plain lookup key so verifying does not mean hashing against
+/// every row, and only a hash of the secret is stored — a database read must
+/// not yield a usable reset token, or a stolen dump becomes a way into every
+/// account with an address on file.
+///
+/// SHA-256 rather than bcrypt here, deliberately. A reset secret is 32 bytes of
+/// system randomness, not a human-chosen password, so there is nothing for a
+/// slow hash to buy: an attacker cannot guess it faster than the search space
+/// allows, and the cost would be paid on every legitimate reset instead.
+final class PasswordResetToken: Model, @unchecked Sendable {
+ static let schema = "password_resets"
+
+ @ID(custom: "id", generatedBy: .database) var id: Int?
+ @Field(key: "user_id") var userID: Int
+ @Field(key: "selector") var selector: String
+ @Field(key: "secret_hash") var secretHash: String
+ @Field(key: "expires_at") var expiresAt: Date
+ @OptionalField(key: "used_at") var usedAt: Date?
+ @Timestamp(key: "created_at", on: .create) var createdAt: Date?
+
+ init() {}
+}
+
+struct CreatePasswordResetToken: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(PasswordResetToken.schema)
+ .field("id", .int, .identifier(auto: true))
+ .field("user_id", .int, .required)
+ .field("selector", .string, .required)
+ .field("secret_hash", .string, .required)
+ .field("expires_at", .datetime, .required)
+ .field("used_at", .datetime)
+ .field("created_at", .datetime)
+ .unique(on: "selector")
+ .create()
+ try await (database as? any SQLDatabase)?
+ .raw("CREATE INDEX IF NOT EXISTS password_resets_user ON password_resets(user_id)")
+ .run()
+ }
+ func revert(on database: any Database) async throws {
+ try await database.schema(PasswordResetToken.schema).delete()
+ }
+}
+
+extension AccountRoutes {
+
+ /// One hour. Long enough to find the mail in a spam folder, short enough
+ /// that a link left in an inbox is not a standing key to the account.
+ static var resetLifetime: TimeInterval { 3600 }
+
+ /// Resets requested per address per window. Low, because each one sends
+ /// mail to somebody who did not necessarily ask for it — an unthrottled
+ /// route here is a way to use this relay to harass a stranger's inbox.
+ static var resetMaxPerWindow: Int { 3 }
+
+ // MARK: - POST /account/reset/request
+
+ /// Begin a reset. **Always answers the same way**, whatever happens.
+ ///
+ /// A different answer for "no such account", "no address on file" or "that
+ /// one signs in with FlameNet" turns this route into a way to ask the
+ /// server which usernames exist and how they authenticate. So the response
+ /// is fixed and the branching is all internal — including the case where
+ /// mail is not configured at all.
+ func requestReset(_ request: Request, mailer: Mailer?, siteURL: String,
+ now: Int) async throws -> Response {
+ struct Input: Content { let handle: String }
+ let input = try request.content.decode(Input.self)
+ let handle = User.normalize(input.handle)
+
+ let sameAnswer = try Response.json(MobileMessageOnly(
+ message: "If that account exists and has an email address, a reset link is on its way."))
+
+ guard !handle.isEmpty else { return sameAnswer }
+ guard await limits.consume("reset:\(handle)", limit: Self.resetMaxPerWindow, now: now)
+ else { return sameAnswer }
+
+ // By username or by address — people remember one or the other.
+ let user = try await User.query(on: request.db)
+ .group(.or) { $0.filter(\.$username == handle).filter(\.$email == handle) }
+ .first()
+
+ guard let user, let userID = user.id, let email = user.email, !email.isEmpty,
+ // An account authenticated by the shared FlameNet service has no
+ // password here to reset. Sending a link that sets a hash nothing
+ // checks would be worse than sending nothing.
+ user.enclavePseudonym == nil,
+ let mailer
+ else { return sameAnswer }
+
+ // Any earlier token for this user stops working. Two live links is a
+ // longer window than one for no benefit.
+ try await PasswordResetToken.query(on: request.db)
+ .filter(\.$userID == userID).delete()
+
+ let selector = Self.randomToken(bytes: 8)
+ let secret = Self.randomToken(bytes: 32)
+ let row = PasswordResetToken()
+ row.userID = userID
+ row.selector = selector
+ row.secretHash = SHA256.hash(data: Data(secret.utf8))
+ .map { String(format: "%02x", $0) }.joined()
+ row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) + Self.resetLifetime)
+ try await row.create(on: request.db)
+
+ let token = "\(selector).\(secret)"
+ let body = """
+ Somebody asked to reset the password for \(user.username) on \(siteURL).
+
+ Paste this code into the app to choose a new one:
+
+ \(token)
+
+ It works once and expires in an hour. If this was not you, nothing has
+ happened yet and you can ignore this message — but somebody knows your
+ username, so it is worth having a password nobody else could guess.
+ """
+
+ // Sent inline rather than queued: this relay has no job runner, and a
+ // fire-and-forget task would swallow the failure. A mail server that is
+ // down should surface in the logs, not vanish.
+ do {
+ try await mailer.send(to: email, subject: "Reset your Flamenet password", body: body)
+ } catch {
+ // The token is deleted again, because a token nobody received is a
+ // live credential in the database with no owner.
+ try? await row.delete(on: request.db)
+ request.logger.error("password reset mail failed: \(error)")
+ }
+ return sameAnswer
+ }
+
+ // MARK: - POST /account/reset/confirm
+
+ /// Finish a reset: token plus a new password.
+ func confirmReset(_ request: Request, now: Int) async throws -> Response {
+ struct Input: Content { let token: String; let password: String }
+ let input = try request.content.decode(Input.self)
+
+ guard input.password.count >= 8 else {
+ throw WireError("weak_password", "Passwords must be at least 8 characters.", 400)
+ }
+ let parts = input.token.trimmingCharacters(in: .whitespacesAndNewlines)
+ .split(separator: ".", maxSplits: 1)
+ guard parts.count == 2 else {
+ throw WireError("bad_token", "That reset code is not valid.", 400)
+ }
+
+ guard let row = try await PasswordResetToken.query(on: request.db)
+ .filter(\.$selector == String(parts[0])).first(),
+ row.usedAt == nil,
+ row.expiresAt.timeIntervalSince1970 > TimeInterval(now)
+ else {
+ throw WireError("bad_token", "That reset code has expired or has already been used.", 400)
+ }
+
+ let offered = SHA256.hash(data: Data(String(parts[1]).utf8))
+ .map { String(format: "%02x", $0) }.joined()
+ guard constantTimeEquals(Data(offered.utf8), Data(row.secretHash.utf8)) else {
+ throw WireError("bad_token", "That reset code is not valid.", 400)
+ }
+
+ guard let user = try await User.find(row.userID, on: request.db) else {
+ throw WireError("bad_token", "That reset code is not valid.", 400)
+ }
+
+ user.passwordHash = try Bcrypt.hash(input.password)
+ try await user.update(on: request.db)
+
+ row.usedAt = Date(timeIntervalSince1970: TimeInterval(now))
+ try await row.update(on: request.db)
+
+ // Every existing credential is revoked. Somebody resetting a password
+ // may be doing it because another person has their account, and leaving
+ // that person's session working is the one outcome this flow must not
+ // produce. It signs the legitimate user out of their own devices too,
+ // which is the right trade.
+ try await AppCredential.query(on: request.db)
+ .filter(\.$userID == row.userID).delete()
+
+ return try .json(MobileMessageOnly(
+ message: "Password changed. Sign in again on each of your devices."))
+ }
+
+ /// URL-safe random, for the two halves of a reset token.
+ static func randomToken(bytes: Int) -> String {
+ var data = Data(count: bytes)
+ _ = data.withUnsafeMutableBytes { SystemRandomNumberGenerator.fill($0) }
+ return data.map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+private extension SystemRandomNumberGenerator {
+ static func fill(_ buffer: UnsafeMutableRawBufferPointer) {
+ var generator = SystemRandomNumberGenerator()
+ for index in buffer.indices { buffer[index] = UInt8.random(in: 0...255, using: &generator) }
+ }
+}
diff --git a/Sources/FlamenetRelay/Retention.swift b/Sources/FlamenetRelay/Retention.swift
index 1a01b89..d592a8b 100644
--- a/Sources/FlamenetRelay/Retention.swift
+++ b/Sources/FlamenetRelay/Retention.swift
@@ -72,7 +72,7 @@ struct Retention {
// its deadline is a broken promise rather than a full disk.
// Counted before deleting, because Fluent's delete() reports
// nothing and a sweep that cannot say what it removed is the
- // same unverifiable claim the wp-cron purge used to make.
+ // same unverifiable claim the old external-cron purge used to make.
let lapsed = try await Message.query(on: db)
.filter(\.$expiresAt < Date()).count()
try await Message.query(on: db)
diff --git a/Sources/FlamenetRelay/Routes.swift b/Sources/FlamenetRelay/Routes.swift
index 8e3a1ca..44c034c 100644
--- a/Sources/FlamenetRelay/Routes.swift
+++ b/Sources/FlamenetRelay/Routes.swift
@@ -15,6 +15,11 @@ struct Routes: Sendable {
let config: Config
let verifier: TokenVerifier
let limits: RateLimiter
+ /// Server-to-server verification. Disabled unless an operator opted in.
+ let federation: FederationVerifier
+ /// Outbound half. Nil on a closed relay, which signs nothing for anyone —
+ /// so the routes that need it refuse with the policy rather than failing.
+ let federationSender: FederationSender?
let replay: ReplayCache
static let maxBodyBytes = 4 * 1024 * 1024
@@ -149,8 +154,10 @@ struct Routes: Sendable {
// SPK rotation republishes the same identity, and appending on
// every such call would bury the entries that mean something.
if identityChanged {
+ let subject = try await KeyLog.subject(
+ forUserID: caller.userID, audience: config.audience, on: db)
try await KeyLog.append(
- userID: caller.userID, deviceID: input.device_id,
+ userID: caller.userID, subject: subject, deviceID: input.device_id,
ikSig: input.ik_sig, ikDH: input.ik_dh, kind: "publish", on: db)
}
@@ -186,6 +193,20 @@ struct Routes: Sendable {
throw WireError.rateLimited("Too many key requests recently. Try again shortly.")
}
+ return try await bundlesFor(userID: target, on: request)
+ }
+
+ /// Build the bundle response for an account, popping a one-time prekey per
+ /// device.
+ ///
+ /// Split out so the federated route serves byte-identical bundles rather
+ /// than a second implementation that can drift — the §12/§20 gating below
+ /// is subtle enough that two copies would eventually disagree about which
+ /// fields a peer sees.
+ ///
+ /// Callers are responsible for authorisation and for metering the pop; this
+ /// does neither, because the two callers answer to different authorities.
+ func bundlesFor(userID target: Int, on request: Request) async throws -> Response {
let devices = try await Device.query(on: request.db)
.filter(\.$userID == target).all()
guard !devices.isEmpty else { throw WireError.notFound("No such user.") }
@@ -218,7 +239,8 @@ struct Routes: Sendable {
bundle.spk_pq_sig = d.spkPQSig
out.append(bundle)
}
- return try .json(BundleResponse(user_id: target, devices: out))
+ let targetFnid = try await User.find(target, on: request.db)?.fnid
+ return try .json(BundleResponse(user_id: target, fnid: targetFnid, devices: out))
}
// MARK: - §8.3 GET /e2e/devices/{user_id}
@@ -226,6 +248,11 @@ struct Routes: Sendable {
func listDevices(_ request: Request, target: Int, now: Int) async throws -> Response {
_ = try await authenticate(
request, body: Data(), path: "/e2e/devices/\(target)", now: now)
+ return try await devicesFor(userID: target, on: request)
+ }
+
+ /// Identity keys for an account. Pops nothing.
+ func devicesFor(userID target: Int, on request: Request) async throws -> Response {
let devices = try await Device.query(on: request.db)
.filter(\.$userID == target).all()
return try .json(DeviceListResponse(
@@ -263,8 +290,10 @@ struct Routes: Sendable {
try await Envelope.query(on: db).filter(\.$toDevice == deviceID).delete()
// Recorded before the row goes: a log that only ever grows is the
// point, so a device leaving is an entry rather than a deletion.
+ let subject = try await KeyLog.subject(
+ forUserID: device.userID, audience: config.audience, on: db)
try await KeyLog.append(
- userID: device.userID, deviceID: deviceID,
+ userID: device.userID, subject: subject, deviceID: deviceID,
ikSig: device.ikSig, ikDH: device.ikDH, kind: "revoke", on: db)
try await device.delete(on: db)
}
@@ -397,6 +426,7 @@ struct Routes: Sendable {
e.fromUser = senderUser
e.fromDevice = senderDevice
e.payload = m.payload
+ e.envelopeID = m.envelope_id
e.delivered = false
try await e.create(on: db)
if let id = e.id { written.append(id) }
@@ -434,7 +464,8 @@ struct Routes: Sendable {
cursor = max(cursor, e.id ?? cursor)
return .init(
id: e.id ?? 0, from_user: e.fromUser, from_device: e.fromDevice,
- payload: e.payload, created_at: iso8601(e.createdAt))
+ payload: e.payload, envelope_id: e.envelopeID,
+ created_at: iso8601(e.createdAt))
}
if !messages.isEmpty {
try await Envelope.query(on: request.db)
diff --git a/Sources/FlamenetRelay/RoutesAccount.swift b/Sources/FlamenetRelay/RoutesAccount.swift
index 75a0693..3dae916 100644
--- a/Sources/FlamenetRelay/RoutesAccount.swift
+++ b/Sources/FlamenetRelay/RoutesAccount.swift
@@ -6,8 +6,8 @@ import Vapor
/// `/account/*` — the built-in identity provider.
///
/// Ported from the plugin's `FNMSG_REST_Account`, which exchanged a username
-/// and password for a WordPress Application Password so the native app never
-/// drove wp-login.php. Same shape here, minus WordPress: sign up, sign in for a
+/// and password for a long-lived credential, so the native app never drove a
+/// web login form. Same shape here: sign up, sign in for a
/// long-lived credential, then exchange that credential for short-lived
/// device-bound relay tokens.
struct AccountRoutes: Sendable {
@@ -134,10 +134,12 @@ struct AccountRoutes: Sendable {
/// Off unless `FN_ENCLAVE_JWT_SECRET` is set. A self-hosted relay has no
/// account service, and a route that verifies tokens against a secret
/// nobody configured would accept nothing while looking like it might.
- func enclaveSession(_ request: Request, secret: String, now: Int) async throws -> Response {
+ func enclaveSession(_ request: Request, secret: String, publicKey: Data?,
+ now: Int) async throws -> Response {
let input = try request.content.decode(EnclaveSessionRequest.self)
- let claims = try Self.verifyEnclaveToken(input.token, secret: secret, now: now)
+ let claims = try Self.verifyEnclaveToken(
+ input.token, secret: secret, publicKey: publicKey, now: now)
// Look up by pseudonym first: after the first sign-in the handle is
// irrelevant, and honouring it would let somebody rename themselves
@@ -186,22 +188,63 @@ struct AccountRoutes: Sendable {
/// the header rather than trusting it — `alg: none`, and HS256 tokens
/// verified against an RSA public key, are the two classic JWT forgeries,
/// and the first costs nothing to prevent.
+ /// Verify a session minted by the FlameNet account service.
+ ///
+ /// Two algorithms are accepted, and which one a deployment can use decides
+ /// whether it may be run by somebody else:
+ ///
+ /// - **EdDSA** verifies against a published public key. The relay can check
+ /// a session and cannot mint one, so a relay this project does not
+ /// operate can accept FlameNet identities without being handed authority
+ /// over them. This is the path that makes a self-hosted relay on shared
+ /// accounts possible at all.
+ /// - **HS256** verifies against a shared secret. It works, and it is the
+ /// reason third-party relays were never an option: the secret that checks
+ /// a token also makes one, so any relay holding it could impersonate any
+ /// account. Kept only until the account service has finished moving, and
+ /// accepted ONLY when no public key is configured — a relay that has been
+ /// given a verifying key must not be talked back down to a shared secret
+ /// by a token that simply says `alg: HS256`.
static func verifyEnclaveToken(_ token: String, secret: String,
+ publicKey: Data? = nil,
now: Int) throws -> (subject: String, expires: Double) {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3 else { throw WireError.unauthorized("Malformed token.") }
guard let headerData = Data(base64URLEncoded: String(parts[0])),
let header = try? JSONSerialization.jsonObject(with: headerData) as? [String: Any],
- (header["alg"] as? String) == "HS256"
+ let alg = header["alg"] as? String
else { throw WireError.unauthorized("Unsupported token algorithm.") }
let signed = "\(parts[0]).\(parts[1])"
- let expected = HMAC.authenticationCode(
- for: Data(signed.utf8), using: SymmetricKey(data: Data(secret.utf8)))
- guard let provided = Data(base64URLEncoded: String(parts[2])),
- constantTimeEquals(Data(expected), provided)
- else { throw WireError.unauthorized("Token signature does not verify.") }
+ guard let provided = Data(base64URLEncoded: String(parts[2])) else {
+ throw WireError.unauthorized("Token signature does not verify.")
+ }
+
+ switch (alg, publicKey) {
+ case ("EdDSA", .some(let raw)):
+ guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw),
+ key.isValidSignature(provided, for: Data(signed.utf8))
+ else { throw WireError.unauthorized("Token signature does not verify.") }
+
+ case ("HS256", .none):
+ let expected = HMAC.authenticationCode(
+ for: Data(signed.utf8), using: SymmetricKey(data: Data(secret.utf8)))
+ guard constantTimeEquals(Data(expected), provided) else {
+ throw WireError.unauthorized("Token signature does not verify.")
+ }
+
+ case ("HS256", .some):
+ // Downgrade, refused. A relay configured with a public key has been
+ // told what to expect; accepting a MAC here would let anyone who
+ // ever learned the old shared secret keep minting sessions for it
+ // forever, which is exactly what moving to a signature was for.
+ throw WireError.unauthorized(
+ "This relay verifies signed sessions; a MAC-signed token is not accepted.")
+
+ default:
+ throw WireError.unauthorized("Unsupported token algorithm.")
+ }
guard let payloadData = Data(base64URLEncoded: String(parts[1])),
let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
@@ -218,14 +261,14 @@ struct AccountRoutes: Sendable {
return (subject, exp)
}
- // MARK: - GET /wp/v2/users/me
+ // MARK: - GET /messenger/me
/// The app validates a cached credential by fetching this before trusting
/// it, so a session restore fails closed rather than surfacing errors later
/// on every screen at once.
///
- /// `slug` and `name` are WordPress's field names, kept because the client
- /// decodes them. There is no WordPress here.
+ /// `slug` and `name` are the field names the client decodes. They are the
+ /// only thing left from an older shape; the service behind them is ours.
func me(_ request: Request) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db),
@@ -234,17 +277,17 @@ struct AccountRoutes: Sendable {
return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username))
}
- // MARK: - GET /wp/v2/users?slug=
+ // MARK: - GET /messenger/users?slug=
/// Look up one member by exact username.
///
- /// Deliberately *not* a directory. The WordPress collection this replaces
+ /// Deliberately *not* a directory. The collection this replaces
/// returned every account on the site, which is a feature on a community
/// blog and a downloadable list of everyone who uses the service on a
/// messenger. A caller who already knows a username gets that one row; a
/// caller who does not gets nothing to walk.
///
- /// `slug` is WordPress's own filter on this endpoint, so the shipped client
+ /// `slug` is the filter the shipped client already sends,
/// keeps the route it already calls and only stops omitting the parameter.
///
/// Authenticated on purpose: an open lookup is an anonymous
@@ -387,11 +430,11 @@ struct AccountRoutes: Sendable {
extension AccountRoutes {
- /// `POST /wp/v2/users/me` — change the display name, or the password.
+ /// `PUT /messenger/me` — change the display name, or the password.
///
- /// WordPress accepted both fields on this route and the shipped client
+ /// Both fields are accepted on this route because the shipped client
/// still posts to it, so the shape is kept. What is not kept is
- /// WordPress's willingness to change a password it does not own.
+ /// a willingness to change a password the caller does not own.
func updateMe(_ request: Request) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db),
@@ -432,7 +475,7 @@ extension AccountRoutes {
try await user.update(on: request.db)
// Existing app credentials deliberately survive a password change, as
- // they did on WordPress: they are separate credentials, and revoking
+ // 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. `DELETE /account` is the lever
// for "end everything".
diff --git a/Sources/FlamenetRelay/RoutesDiscovery.swift b/Sources/FlamenetRelay/RoutesDiscovery.swift
new file mode 100644
index 0000000..bc0062a
--- /dev/null
+++ b/Sources/FlamenetRelay/RoutesDiscovery.swift
@@ -0,0 +1,117 @@
+import Crypto
+import Foundation
+import Vapor
+
+/// How a relay describes itself to somebody who has only its hostname.
+///
+/// A person types one address. Everything else a client or a peer needs — which
+/// key signs this relay's tokens, which protocol versions it speaks, whether it
+/// will talk to other relays at all — has to be discoverable from that one
+/// string, or federation becomes a configuration exercise that nobody completes.
+///
+/// This follows the shape the Files server already established with
+/// `/api/config`: the server names its own trust anchors, and the client holds
+/// no built-in list of which hostnames are "ours". A hardcoded list is wrong the
+/// moment somebody forks this, which is the entire point of the project.
+///
+/// **Unauthenticated, deliberately.** Every field is either already public (the
+/// issuer key verifies tokens the relay hands out) or is a statement about the
+/// deployment rather than about anyone using it. A descriptor only this relay's
+/// own users could read would be useless to the peers it exists to inform.
+extension Routes {
+
+ struct RelayDescriptor: Content {
+ /// The `aud` this relay accepts and mints. The canonical name — a relay
+ /// reachable at several hostnames still has exactly one of these, and
+ /// this is the one to address it by.
+ let audience: String
+ /// Base64 Ed25519 public key of the token issuer.
+ ///
+ /// Published so a peer can verify a token this relay minted without
+ /// asking this relay to verify it — which is what stops a compromised
+ /// relay from being able to assert anything it likes about its users to
+ /// somebody else.
+ ///
+ /// ⚠️ Publishing it is not the same as making it trustworthy. A client
+ /// fetching this over TLS learns what key *this host* claims; it does
+ /// not learn that the host is who it should be. That is what the
+ /// transparency log and key pinning are for, and no discovery document
+ /// can substitute for them.
+ let issuerPublicKey: String
+ /// Protocol versions the relay can serve, lowest first.
+ let protocolVersions: [Int]
+ /// Where the transparency log lives, so a monitor that knows only the
+ /// hostname can start following it.
+ let keyLogHead: String
+ /// Whether this relay speaks to other relays, and on what terms.
+ ///
+ /// `closed` is the honest answer today and the correct default for a
+ /// self-hoster: server-to-server delivery does not exist yet, and a
+ /// relay that advertised `open` would be promising something no code
+ /// behind it can do. A peer reads this before attempting anything, so a
+ /// wrong value here is a failure with no useful error rather than a
+ /// polite refusal.
+ let federation: String
+ /// Base64 Ed25519 public key this relay signs server-to-server requests
+ /// with. Null on a closed relay, which signs nothing for anyone.
+ ///
+ /// Separate from `issuerPublicKey` on purpose: that one mints user
+ /// tokens, this one only ever says "this relay said this". A peer that
+ /// confused them would accept a relay's self-assertion as authority
+ /// over its users.
+ let federationKey: String?
+ /// Whether accounts here come from the shared FlameNet account service
+ /// or from this relay's own database.
+ ///
+ /// A client needs this before it sends a credential anywhere: the two
+ /// modes stretch the passphrase differently and send the result to
+ /// different hosts. Guessing wrong means sending an authentication
+ /// secret to a service that was never meant to receive it.
+ let accounts: String
+ /// Absolute URL of that account service, when there is one.
+ let accountsURL: String?
+ }
+
+ /// `GET /.well-known/flamenet/relay`
+ func relayDescriptor(_ request: Request) async throws -> Response {
+ // Either credential means this relay accepts account-service sessions:
+ // a public key to verify them, or the older shared secret.
+ let shared = (Environment.get("FN_ENCLAVE_JWT_SECRET").map { $0.count >= 32 } ?? false)
+ || (Environment.get("FN_ENCLAVE_PUBLIC_KEY").map { !$0.isEmpty } ?? false)
+ return try .json(RelayDescriptor(
+ audience: config.audience,
+ issuerPublicKey: config.issuerKeyRaw.base64EncodedString(),
+ // v1 through v3 are the ratchet's, not this document's; the relay
+ // stores opaque payloads and does not care which, but a peer
+ // deciding whether it can talk to us does.
+ protocolVersions: [1, 2, 3],
+ keyLogHead: "https://\(config.audience)/e2e/log/head",
+ federation: config.federationPolicy,
+ federationKey: config.federationPolicy == "closed" ? nil
+ : (try? Curve25519.Signing.PrivateKey(rawRepresentation: config.federationKeyRaw ?? Data()))
+ .map { $0.publicKey.rawRepresentation.base64EncodedString() },
+ accounts: shared ? "shared" : "local",
+ accountsURL: shared ? Self.accountsURL : nil
+ ))
+ }
+
+ /// The account service this relay authenticates against.
+ ///
+ /// Configurable, because a self-hoster running both products wants one
+ /// account across them and their own service to hold it — the file server
+ /// has taken `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and
+ /// a relay that could not do the same made "run Files and Messenger
+ /// together" mean "run them with two separate logins".
+ ///
+ /// The obvious worry — an operator pointing this at a host they control and
+ /// harvesting passphrase-derived secrets — is answered on the client, not
+ /// here. The app keeps its own list of hostnames that map to
+ /// accounts.flamenet.io and ignores what those relays advertise; for any
+ /// other relay, the user typed that hostname themselves, and a relay they
+ /// chose naming its own account service is the choice they already made. A
+ /// relay cannot use this to redirect somebody else's users.
+ static var accountsURL: String {
+ Environment.get("FN_ACCOUNTS_URL").flatMap { $0.isEmpty ? nil : $0 }
+ ?? "https://accounts.flamenet.io"
+ }
+}
diff --git a/Sources/FlamenetRelay/RoutesDonate.swift b/Sources/FlamenetRelay/RoutesDonate.swift
index 00e4220..eeea270 100644
--- a/Sources/FlamenetRelay/RoutesDonate.swift
+++ b/Sources/FlamenetRelay/RoutesDonate.swift
@@ -1,19 +1,25 @@
import Foundation
import Vapor
-/// Donations, by hand-off rather than by embedding.
+/// Donations, taken on this page rather than on the processor's.
///
-/// The page collects an amount and posts it here; this route opens a session
-/// with the processor and redirects. Three reasons it is built this way:
+/// The card fields are Stripe's own, mounted in an iframe this origin cannot
+/// read, and the card number never reaches this process, this server or its
+/// logs. What changed from the previous version is only *where the person is
+/// standing*: no redirect off the site and back.
///
-/// 1. **The CSP.** This origin runs `default-src 'self'`, and an embedded
-/// processor SDK is a third-party script by definition. Redirecting keeps
-/// the guarantee the rest of the site rests on.
-/// 2. **Card data.** It never touches this origin, this process, or its logs,
-/// because it is entered on the processor's own page. That is also the only
-/// version of this a small project should be running.
-/// 3. **Failing closed.** Unconfigured, there is no form and no route — rather
-/// than a button that dies after someone has decided to give.
+/// ⚠️ **This origin therefore loads `js.stripe.com`.** That is a third-party
+/// script, and the CSP is widened for it and for Stripe's frames — narrowly,
+/// naming Stripe rather than relaxing `default-src`. The privacy page says so;
+/// if this route changes again, that page has to change with it.
+///
+/// ⚠️ **Never accept raw card details here.** An "internal" payment form that
+/// posts a PAN to this server would put card data in this process and its logs
+/// and pull the whole deployment into PCI scope. The Payment Element exists so
+/// that it does not have to.
+///
+/// Unconfigured, there is no form and no route — rather than a button that dies
+/// after someone has decided to give.
struct DonateRoutes: Sendable {
let config: Config
@@ -29,15 +35,42 @@ struct DonateRoutes: Sendable {
let custom_amount: String?
}
- func checkout(_ request: Request) async throws -> Response {
- guard let provider = config.donateProvider, let secret = config.donateSecret else {
+ /// What the page needs to mount the Payment Element.
+ struct IntentResponse: Content {
+ let clientSecret: String
+ let publishableKey: String
+ let amount: Int
+ let recurring: Bool
+ /// The connected account this payment was opened on, or nil.
+ ///
+ /// The browser has to build its Stripe instance with it. A client
+ /// secret belonging to a connected account cannot be confirmed by a
+ /// Stripe scoped to the platform — it fails as "No such payment_intent",
+ /// naming the intent rather than the account, after the card has been
+ /// typed in.
+ let stripeAccount: String?
+ }
+
+ /// Open a payment on the server and hand the browser only what it needs.
+ ///
+ /// The client secret authorises confirming exactly this one payment and
+ /// nothing else; the publishable key is public by definition. The API
+ /// secret stays here.
+ func intent(_ request: Request) async throws -> IntentResponse {
+ guard let provider = config.donateProvider,
+ let secret = config.donateSecret,
+ let publishable = config.donatePublishableKey else {
throw WireError("donations_disabled",
"This relay has no payment processor configured.", 503)
}
- let form = try request.content.decode(Form.self)
+ guard provider.lowercased() == "stripe" else {
+ throw WireError("donations_misconfigured",
+ "Unknown payment processor: \(provider).", 500)
+ }
+ let form = try request.content.decode(Form.self)
// Resolve the amount before talking to anyone: an invalid one should
- // fail here, not after a redirect the person has to come back from.
+ // fail here, while the person is still looking at the field.
let raw = (form.amount == "custom" ? form.custom_amount : form.amount) ?? ""
guard let dollars = Int(raw.trimmingCharacters(in: .whitespaces)),
dollars >= Self.minAmount, dollars <= Self.maxAmount
@@ -47,69 +80,122 @@ struct DonateRoutes: Sendable {
}
let recurring = (form.frequency ?? "once") == "monthly"
- switch provider.lowercased() {
- case "stripe":
- let url = try await stripeSession(
- dollars: dollars, recurring: recurring, secret: secret, request: request)
- // 303 so the browser follows with GET: a refresh of the result page
- // must not re-post the form and open a second session.
- return request.redirect(to: url, redirectType: .normal)
- default:
- throw WireError("donations_misconfigured",
- "Unknown payment processor: \(provider).", 500)
- }
+ let clientSecret = recurring
+ ? try await subscriptionSecret(dollars: dollars, secret: secret, request: request)
+ : try await paymentIntentSecret(dollars: dollars, secret: secret, request: request)
+
+ return IntentResponse(clientSecret: clientSecret, publishableKey: publishable,
+ amount: dollars, recurring: recurring,
+ stripeAccount: config.donateAccount)
}
- /// Create a Stripe Checkout Session and return its hosted URL.
- ///
- /// Deliberately no Stripe SDK: one form-encoded POST is the whole
- /// integration, and a dependency that ships its own HTTP stack into a relay
- /// this size is not worth the attack surface.
- private func stripeSession(
- dollars: Int, recurring: Bool, secret: String, request: Request
+ // MARK: - Stripe
+
+ /// A one-off payment.
+ private func paymentIntentSecret(
+ dollars: Int, secret: String, request: Request
) async throws -> String {
- let base = "\(request.headers.first(name: .init("X-Forwarded-Proto")) ?? "https")://"
- + (request.headers.first(name: .host) ?? "localhost")
+ let json = try await stripePOST("payment_intents", secret: secret, request: request, fields: [
+ "amount": String(dollars * 100),
+ "currency": "usd",
+ "description": "Flamenet Messenger — donation",
+ // Let Stripe decide which methods to offer, so the Element shows
+ // whatever the account actually supports rather than cards only.
+ "automatic_payment_methods[enabled]": "true",
+ ])
+ guard let clientSecret = json["client_secret"] as? String else {
+ throw Self.unavailable(request, "payment_intents had no client_secret")
+ }
+ return clientSecret
+ }
- var fields: [String: String] = [
- "mode": recurring ? "subscription" : "payment",
- "success_url": "\(base)/donate?thanks=1",
- "cancel_url": "\(base)/donate",
- "line_items[0][quantity]": "1",
- "line_items[0][price_data][currency]": "usd",
- "line_items[0][price_data][unit_amount]": String(dollars * 100),
- "line_items[0][price_data][product_data][name]":
- recurring ? "Flamenet Messenger — monthly support"
- : "Flamenet Messenger — donation",
- ]
- if recurring { fields["line_items[0][price_data][recurring][interval]"] = "month" }
+ /// A monthly subscription, confirmed on this page like a one-off.
+ ///
+ /// `payment_behavior=default_incomplete` is what makes that possible: the
+ /// subscription is created unpaid, and its first invoice carries the secret
+ /// the Payment Element confirms.
+ ///
+ /// ⚠️ The secret is read from `latest_invoice.confirmation_secret`.
+ /// `invoice.payment_intent` was the old field and is **gone** — code written
+ /// against it fails at run time with a null, not at compile time.
+ private func subscriptionSecret(
+ dollars: Int, secret: String, request: Request
+ ) async throws -> String {
+ // A price, then a customer, then the subscription. Inline price data is
+ // not accepted on subscriptions the way it is on Checkout.
+ let price = try await stripePOST("prices", secret: secret, request: request, fields: [
+ "currency": "usd",
+ "unit_amount": String(dollars * 100),
+ "recurring[interval]": "month",
+ "product_data[name]": "Flamenet Messenger — monthly support",
+ ])
+ guard let priceID = price["id"] as? String else {
+ throw Self.unavailable(request, "price creation returned no id")
+ }
+ let customer = try await stripePOST("customers", secret: secret, request: request,
+ fields: ["description": "Flamenet donor"])
+ guard let customerID = customer["id"] as? String else {
+ throw Self.unavailable(request, "customer creation returned no id")
+ }
+ let subscription = try await stripePOST(
+ "subscriptions", secret: secret, request: request, fields: [
+ "customer": customerID,
+ "items[0][price]": priceID,
+ "payment_behavior": "default_incomplete",
+ "payment_settings[save_default_payment_method]": "on_subscription",
+ "expand[0]": "latest_invoice.confirmation_secret",
+ ])
+ guard let invoice = subscription["latest_invoice"] as? [String: Any],
+ let confirmation = invoice["confirmation_secret"] as? [String: Any],
+ let clientSecret = confirmation["client_secret"] as? String else {
+ throw Self.unavailable(request, "subscription had no confirmation_secret")
+ }
+ return clientSecret
+ }
- let body = fields.map { k, v in
- "\(k.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? k)="
- + (v.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? v)
+ /// One form-encoded POST is the whole integration.
+ ///
+ /// Deliberately no Stripe SDK: a dependency that ships its own HTTP stack
+ /// into a relay this size is not worth the attack surface.
+ private func stripePOST(
+ _ path: String, secret: String, request: Request, fields: [String: String]
+ ) async throws -> [String: Any] {
+ let body = fields.map { key, value in
+ "\(key.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? key)="
+ + (value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value)
}.joined(separator: "&")
var headers = HTTPHeaders()
headers.add(name: .authorization, value: "Bearer \(secret)")
headers.add(name: .contentType, value: "application/x-www-form-urlencoded")
+ // Open the payment *on the connected account*, not on the platform the
+ // key authenticates as. One header is the whole of it: the intent, the
+ // money, the fees and the receipt are then its own. Omitting it is not
+ // an error Stripe reports — it succeeds, against the wrong account.
+ if let account = config.donateAccount {
+ headers.add(name: "Stripe-Account", value: account)
+ }
let response = try await request.client.post(
- "https://api.stripe.com/v1/checkout/sessions",
- headers: headers
+ "https://api.stripe.com/v1/\(path)", headers: headers
) { req in req.body = .init(string: body) }
guard response.status == .ok,
let data = response.body.flatMap({ Data(buffer: $0) }),
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let url = json["url"] as? String
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
- // The processor's own error text is not shown: it is written for a
- // developer, and may name the account.
- request.logger.error("stripe checkout session failed",
- metadata: ["status": .string(response.status.description)])
- throw WireError("donations_unavailable",
- "Could not start a payment session. Please try again later.", 502)
+ throw Self.unavailable(request, "\(path) returned \(response.status)")
}
- return url
+ return json
+ }
+
+ /// One message for every processor failure.
+ ///
+ /// Stripe's own error text is not shown: it is written for a developer, and
+ /// it may name the account.
+ private static func unavailable(_ request: Request, _ detail: String) -> WireError {
+ request.logger.error("stripe call failed", metadata: ["detail": .string(detail)])
+ return WireError("donations_unavailable",
+ "Could not start a payment. Please try again later.", 502)
}
}
diff --git a/Sources/FlamenetRelay/RoutesFederation.swift b/Sources/FlamenetRelay/RoutesFederation.swift
new file mode 100644
index 0000000..d02fed4
--- /dev/null
+++ b/Sources/FlamenetRelay/RoutesFederation.swift
@@ -0,0 +1,380 @@
+import Fluent
+import Foundation
+import Vapor
+
+/// The two things a peer relay may ask of this one.
+///
+/// Both are addressed by `fnid`, never by `user_id`. A row number means nothing
+/// to the relay on the other end, and accepting one would mean trusting a peer
+/// to name our users by our own internal identifiers — which it has no way to
+/// know and no business asserting.
+extension Routes {
+
+ /// Resolve an fnid to a local account, or 404.
+ ///
+ /// Answers the same way for "no such account" and "that account has not
+ /// enrolled an identity", which are the same non-answer. A peer learns
+ /// nothing from the distinction that it could not learn by trying to
+ /// deliver, and collapsing them keeps this from being a way to enumerate
+ /// who is here.
+ private func localUser(forFNID fnid: String, on db: any Database) async throws -> User {
+ guard let user = try await User.query(on: db).filter(\.$fnid == fnid).first() else {
+ throw WireError.notFound("no such identity here")
+ }
+ return user
+ }
+
+ private func verifyPeer(_ request: Request, body: Data, path: String, now: Int) async throws
+ -> Federation.Peer
+ {
+ do {
+ return try await federation.verify(
+ method: request.method.string, path: path, body: body,
+ origin: request.headers.first(name: Federation.Headers.origin),
+ timestamp: request.headers.first(name: Federation.Headers.timestamp),
+ nonce: request.headers.first(name: Federation.Headers.nonce),
+ signature: request.headers.first(name: Federation.Headers.signature),
+ now: now)
+ } catch let e as Federation.AuthError {
+ throw WireError(
+ e.status == .forbidden ? "forbidden" : "unauthorized",
+ e.description, Int(e.status.code))
+ }
+ }
+
+ // MARK: - POST /e2e/federation/deposit
+
+ /// Accept ciphertext from a peer, for one of our users.
+ ///
+ /// The relay learns exactly what it learns for a local send: an opaque
+ /// payload, a recipient device, and now the peer that handed it over. It
+ /// does **not** learn the sender — a federated envelope is sealed the same
+ /// way a local one is, and the peer is not asked to name whose it was.
+ ///
+ /// Which is why metering is per origin relay rather than per sender: there
+ /// is no sender to meter, and the depositing relay is the accountable party
+ /// in any case. That is the same shape mail settled on for the same reason.
+ func federationDeposit(_ request: Request, body: Data, now: Int) async throws -> Response {
+ let peer = try await verifyPeer(request, body: body,
+ path: "/e2e/federation/deposit", now: now)
+ let input = try decodeBody(FederationDepositRequest.self, from: body)
+
+ guard input.envelopes.count <= Self.maxFederatedBatch else {
+ throw WireError.badRequest(
+ "at most \(Self.maxFederatedBatch) envelopes per deposit")
+ }
+
+ // One budget for the peer, charged by envelope count. A peer that
+ // exhausts it is throttled without any of its users being singled out,
+ // because we cannot tell them apart and should not be able to.
+ let charged = await limits.consumeAll(
+ [(key: "fed:\(peer.audience)", amount: input.envelopes.count,
+ limit: config.sendRatePerHour)],
+ now: now)
+ guard charged else {
+ throw WireError.rateLimited("peer \(peer.audience) is over its hourly budget here")
+ }
+
+ let (accepted, unknown) = try await request.db.transaction {
+ db -> (Int, [String]) in
+ var accepted = 0
+ var unknown: [String] = []
+ for e in input.envelopes {
+ guard let user = try? await self.localUser(forFNID: e.to_fnid, on: db),
+ let userID = user.id
+ else {
+ // Named, not silently dropped: a peer holding an envelope it
+ // cannot place should stop retrying it, and it can only know
+ // that if we say so. It learns the fnid is not here, which is
+ // something it already believed it knew.
+ unknown.append(e.to_fnid)
+ continue
+ }
+ // The device must belong to that account. Without this a peer
+ // could address any device on this relay by id and have the
+ // envelope filed under someone else's inbox.
+ guard let device = try await Device.find(e.to_device, on: db),
+ device.userID == userID
+ else {
+ unknown.append(e.to_fnid)
+ continue
+ }
+
+ let row = Envelope()
+ row.toUser = userID
+ row.toDevice = e.to_device
+ // Sealed by construction across a federation boundary: we were
+ // not told who sent it and must not invent an attribution.
+ row.fromUser = 0
+ row.fromDevice = ""
+ row.payload = e.payload
+ row.envelopeID = e.envelope_id
+ row.delivered = false
+ try await row.create(on: db)
+ accepted += 1
+ }
+ return (accepted, unknown)
+ }
+
+ return try .json(FederationDepositResponse(
+ ok: true, accepted: accepted, unknown: unknown))
+ }
+
+ /// A batch cap, so one request cannot be an unbounded write.
+ ///
+ /// Modest on purpose: a peer with more than this to deliver makes more
+ /// requests, each individually metered, which is the behaviour we want from
+ /// a busy peer and the behaviour we want to force on an abusive one.
+ static let maxFederatedBatch = 64
+
+ // MARK: - GET /e2e/federation/bundles/:fnid
+
+ /// Serve prekey bundles for one of our users to a peer.
+ ///
+ /// ⚠️ **This pops a one-time prekey per device, exactly as §8.2 does.**
+ /// Across a federation that is a prekey-pool drain with an amplifier on it:
+ /// one hostile peer could exhaust a popular account's pool for everybody.
+ /// So it is metered per origin relay and per (origin, target) pair, using
+ /// the same budgets §8.2 already applies per requester — and deliberately
+ /// *not* metered per target alone, which would let one abuser make an
+ /// account unstartable for the whole federation.
+ ///
+ /// A peer that only needs to know a device's identity key should use the
+ /// listing route instead, which pops nothing.
+ func federationBundles(_ request: Request, fnid: String, now: Int) async throws -> Response {
+ let peer = try await verifyPeer(request, body: Data(),
+ path: "/e2e/federation/bundles/\(fnid)", now: now)
+
+ let charged = await limits.consumeAll([
+ (key: "fedbundle:\(peer.audience)", amount: 1, limit: config.bundleRatePerHour),
+ (key: "fedbundle:\(peer.audience):\(fnid)", amount: 1, limit: config.bundleRatePerTarget),
+ ], now: now)
+ guard charged else {
+ throw WireError.rateLimited("peer \(peer.audience) is over its bundle budget here")
+ }
+
+ let user = try await localUser(forFNID: fnid, on: request.db)
+ guard let userID = user.id else { throw WireError.notFound("no such identity here") }
+ return try await bundlesFor(userID: userID, on: request)
+ }
+
+ // MARK: - GET /e2e/federation/devices/:fnid
+
+ /// Identity keys only. Pops nothing, so it carries the looser budget and is
+ /// what a peer should reach for by default.
+ func federationDevices(_ request: Request, fnid: String, now: Int) async throws -> Response {
+ let peer = try await verifyPeer(request, body: Data(),
+ path: "/e2e/federation/devices/\(fnid)", now: now)
+ let charged = await limits.consumeAll(
+ [(key: "feddev:\(peer.audience)", amount: 1, limit: config.bundleRatePerHour)],
+ now: now)
+ guard charged else {
+ throw WireError.rateLimited("peer \(peer.audience) is over its budget here")
+ }
+
+ let user = try await localUser(forFNID: fnid, on: request.db)
+ guard let userID = user.id else { throw WireError.notFound("no such identity here") }
+ return try await devicesFor(userID: userID, on: request)
+ }
+}
+
+// MARK: - The outbound half: what a local client asks its own relay to do
+
+extension Routes {
+
+ private func requireSender() throws -> FederationSender {
+ guard let sender = federationSender else {
+ throw WireError.forbidden("this relay does not federate")
+ }
+ return sender
+ }
+
+ private func mapped(_ error: any Error) -> any Error {
+ guard let e = error as? FederationSender.SendError else { return error }
+ switch e {
+ case .notPermitted: return WireError.forbidden(e.description)
+ // 502, not 500: the failure is the far end's, and a client that cannot
+ // tell "your relay is broken" from "theirs is unreachable" will report
+ // the wrong outage to the wrong operator.
+ case .unreachable, .refused: return WireError("bad_gateway", e.description, 502)
+ }
+ }
+
+ // MARK: POST /e2e/send/remote
+
+ /// Send to somebody homed at another relay.
+ ///
+ /// A separate route rather than an optional field on `/e2e/send`, because
+ /// the two barely share behaviour: there is no local device to check
+ /// ownership of, delivery is a network call that can fail after the client
+ /// has already committed, and the result is per-address rather than a list
+ /// of row ids. Overloading the local route would have made every one of
+ /// those a special case inside a handler that is currently easy to read.
+ ///
+ /// The relay still learns nothing it does not already learn locally: the
+ /// payload is opaque, and it forwards it without being told whose it is.
+ func sendRemote(_ request: Request, body: Data, now: Int) async throws -> Response {
+ let caller = try await authenticate(
+ request, body: body, path: "/e2e/send/remote", now: now)
+ let input = try decodeBody(SendRemoteRequest.self, from: body)
+ let sender = try requireSender()
+
+ guard !input.messages.isEmpty else { throw WireError.badRequest("No envelopes.") }
+ guard input.messages.count <= Self.maxFederatedBatch else {
+ throw WireError.badRequest(
+ "at most \(Self.maxFederatedBatch) envelopes per request")
+ }
+ guard input.to_relay != config.audience else {
+ // Sending to ourselves through the federation path would work and
+ // would be a waste of a round trip and a signature; more to the
+ // point it would mean two code paths could write the same envelope,
+ // and only one of them enforces device ownership.
+ throw WireError.badRequest("that address is homed here; use /e2e/send")
+ }
+
+ // Metered as a send by the local user, because here there IS a sender to
+ // meter — this is the one federated path where we know who is asking.
+ let admitted = await limits.consumeAll(
+ [(key: "send:\(caller.userID)", amount: input.messages.count,
+ limit: config.sendRatePerHour)],
+ now: now)
+ guard admitted else {
+ throw WireError.rateLimited("Too many messages recently. Try again shortly.")
+ }
+
+ let items = input.messages.map {
+ FederationDepositRequest.Item(
+ to_fnid: input.to_fnid, to_device: $0.to_device,
+ payload: $0.payload, envelope_id: $0.envelope_id)
+ }
+
+ // Where does this person keep mailboxes?
+ //
+ // Asked of the relay the client named, and then VERIFIED here against
+ // the account key the address decodes to. That verification is the
+ // whole reason this is safe to act on: the relay we asked could serve a
+ // truncated list to make itself the only delivery target, and the
+ // signature it cannot forge is what stops that being effective.
+ //
+ // A relay that has no record, or serves one that does not verify, is
+ // simply treated as the single destination the client named. Refusing
+ // outright would make one relay's missing record an undeliverable
+ // message.
+ var destinations = [input.to_relay]
+ if let homes = try? await sender.get(
+ from: input.to_relay, path: "/e2e/account/homes/\(input.to_fnid)", now: now),
+ homes.status == .ok,
+ let record = try? homes.content.decode(HomesResponse.self),
+ record.fnid == input.to_fnid,
+ HomeRelays.verify(fnid: record.fnid, serial: record.serial,
+ relays: record.relays, signatureB64: record.sig),
+ record.relays.count <= HomeRelays.maxHomes,
+ record.relays.contains(input.to_relay) {
+ destinations = record.relays
+ }
+
+ // Fan out. Every destination gets the same ciphertext and the same
+ // envelope id, so the recipient collapses the copies rather than
+ // reading the message several times.
+ var accepted = 0
+ var unknown: [String] = []
+ var reached: [String] = []
+ var failed: [String] = []
+ // Destinations this relay declined to dial at all.
+ var refused: [String] = []
+ for relay in destinations {
+ do {
+ let result = try await sender.deposit(to: relay, envelopes: items, now: now)
+ accepted += result.accepted
+ unknown.append(contentsOf: result.unknown)
+ reached.append(relay)
+ } catch FederationSender.SendError.notPermitted(let peer) {
+ // ⚠️ Refusal is NOT unreachability. This relay declined to dial
+ // a host that is not on its allowlist -- nothing was attempted
+ // and the far end is blameless. Lumping the two together made
+ // a policy decision look like somebody else's outage, and sent
+ // whoever debugged it hunting a network fault that did not
+ // exist.
+ refused.append(peer)
+ } catch {
+ // One home being unreachable is the ordinary case this exists
+ // for, not an error to abort on: the message is already at the
+ // others. Reported so a client can see the delivery was partial
+ // rather than believing it was complete.
+ failed.append(relay)
+ }
+ }
+
+ guard !reached.isEmpty else {
+ // Every destination refused by policy is a 403 about THIS relay's
+ // configuration. Anything else is a 502 about the far end. Saying
+ // which is the difference between an operator editing their
+ // allowlist and an operator paging somebody about a healthy server.
+ if failed.isEmpty, !refused.isEmpty {
+ throw mapped(FederationSender.SendError.notPermitted(
+ refused.joined(separator: ", ")))
+ }
+ throw mapped(FederationSender.SendError.unreachable(
+ (failed + refused).joined(separator: ", ")))
+ }
+ return try .json(SendRemoteResponse(
+ ok: true, accepted: accepted, unknown: Array(Set(unknown)),
+ delivered_to: reached, unreachable: failed))
+ }
+
+ // MARK: GET /e2e/remote/{relay}/devices|bundles/{fnid}
+
+ /// Ask a peer what it publishes about one of its users, on a client's behalf.
+ ///
+ /// Proxied rather than letting the client dial the peer directly, for a
+ /// plain reason: the client has no credential there and the peer has no
+ /// reason to answer it. Relays already authenticate to each other, so the
+ /// hop that exists is the hop that should carry it.
+ ///
+ /// ⚠️ `bundles` pops a one-time prekey per device at the far end. Clients
+ /// should use `devices` for everything except actually establishing a
+ /// session — the same rule §8.2 states for the local pair, and it matters
+ /// more here because the pool being drained belongs to someone else's relay.
+ func remoteDirectory(
+ _ request: Request, relay: String, fnid: String, pops: Bool, now: Int
+ ) async throws -> Response {
+ let caller = try await authenticate(
+ request, body: Data(),
+ path: "/e2e/remote/\(relay)/\(pops ? "bundles" : "devices")/\(fnid)", now: now)
+ let sender = try requireSender()
+
+ let admitted = await limits.consumeAll([
+ (key: "remote:\(caller.userID)", amount: 1, limit: config.bundleRatePerHour),
+ (key: "remote:\(caller.userID):\(fnid)", amount: 1,
+ limit: pops ? config.bundleRatePerTarget : config.bundleRatePerHour),
+ ], now: now)
+ guard admitted else {
+ throw WireError.rateLimited("Too many key requests recently. Try again shortly.")
+ }
+
+ do {
+ let upstream = try await sender.fetch(
+ from: relay, fnid: fnid, pops: pops, now: now)
+ guard upstream.status == .ok else {
+ // Pass the peer's own verdict through rather than flattening it:
+ // 404 there means no such identity, and turning that into a 502
+ // would send a client hunting for a network fault that is not
+ // there.
+ throw WireError(
+ upstream.status == .notFound ? "not_found" : "bad_gateway",
+ upstream.status == .notFound
+ ? "no such identity at \(relay)"
+ : "\(relay) refused the request (\(upstream.status.code))",
+ upstream.status == .notFound ? 404 : 502)
+ }
+ var response = Response(status: .ok)
+ response.headers.replaceOrAdd(name: .contentType, value: "application/json")
+ response.body = .init(buffer: upstream.body ?? ByteBuffer())
+ return response
+ } catch let e as WireError {
+ throw e
+ } catch {
+ throw mapped(error)
+ }
+ }
+}
diff --git a/Sources/FlamenetRelay/RoutesIdentity.swift b/Sources/FlamenetRelay/RoutesIdentity.swift
new file mode 100644
index 0000000..493e9b4
--- /dev/null
+++ b/Sources/FlamenetRelay/RoutesIdentity.swift
@@ -0,0 +1,204 @@
+import Fluent
+import Foundation
+import Vapor
+
+/// Claiming and resolving a global account identity.
+///
+/// Two routes, deliberately asymmetric: claiming is authenticated and proves
+/// possession of a private key, resolving is open and returns only what a
+/// correspondent needs in order to address you.
+extension Routes {
+
+ // MARK: - POST /e2e/account/identity
+
+ /// Register this account's Ed25519 account key, and derive its fnid.
+ ///
+ /// Idempotent for the key already on file, so a client that cannot tell
+ /// whether its last attempt landed can simply repeat it. Publishing a
+ /// *different* key is refused rather than treated as a rotation: rotating
+ /// an account key changes the address, which strands every contact who
+ /// pinned the old one, and that needs a migration record they can follow
+ /// rather than a silent overwrite. Until that exists, the honest answer is
+ /// a refusal that says so.
+ func claimAccountIdentity(_ request: Request, body: Data, now: Int) async throws -> Response {
+ // The canonical /e2e path, not the request's — the proof commits to it,
+ // and the compat alias must not produce a different signed string.
+ let caller = try await authenticate(
+ request, body: body, path: "/e2e/account/identity", now: now)
+ let input = try decodeBody(ClaimIdentityRequest.self, from: body)
+
+ guard let raw = FNID.verifyClaim(
+ accountKeyB64: input.account_key,
+ signatureB64: input.sig,
+ audience: config.audience,
+ userID: caller.userID
+ ) else {
+ throw WireError.badRequest("account key signature does not verify")
+ }
+ let address = FNID.address(forAccountKey: raw)
+
+ guard let user = try await User.find(caller.userID, on: request.db) else {
+ throw WireError.badRequest("no such account")
+ }
+
+ if let existing = user.accountKey {
+ guard existing == input.account_key else {
+ // 409 rather than 400: the request is well-formed and the
+ // signature is good, the account simply already is somebody.
+ throw WireError.conflict(
+ "this account already has a different account key; rotation is not supported yet")
+ }
+ return try .json(ClaimIdentityResponse(
+ ok: true, fnid: address, created: false))
+ }
+
+ // Whoever else already holds this address holds the private key too,
+ // which means it is the same person signing in on a second account
+ // here rather than an attacker. Still refused: two local accounts
+ // sharing one address makes the address ambiguous to every peer that
+ // resolves it, which is the one thing an identity may not be.
+ let taken = try await User.query(on: request.db)
+ .filter(\.$fnid == address).first()
+ if let taken, taken.id != caller.userID {
+ throw WireError.conflict("that identity is already registered to another account here")
+ }
+
+ user.accountKey = input.account_key
+ user.fnid = address
+ do {
+ try await user.update(on: request.db)
+ } catch {
+ // The check above and this write are not one operation, and two
+ // claims arriving together is exactly when that gap is reachable.
+ // The unique index is what actually decides it; this turns the
+ // constraint violation into the same answer the pre-check would
+ // have given, rather than a 500 that tells the client nothing.
+ //
+ // Re-read rather than inspecting the driver's error: SQLite and
+ // Postgres describe a uniqueness failure differently, and this relay
+ // ships on both, so matching on the message would work on one and
+ // quietly stop working on the other.
+ let now = try await User.find(caller.userID, on: request.db)
+ if let mine = now?.fnid, mine == address {
+ return try .json(ClaimIdentityResponse(ok: true, fnid: address, created: false))
+ }
+ if let mine = now?.accountKey, mine != input.account_key {
+ throw WireError.conflict(
+ "this account already has a different account key; rotation is not supported yet")
+ }
+ throw WireError.conflict("that identity is already registered to another account here")
+ }
+
+ return try .json(ClaimIdentityResponse(ok: true, fnid: address, created: true))
+ }
+
+ // MARK: - GET /e2e/account/identity/:handle
+
+ /// Resolve a local handle to the address a peer should use for it.
+ ///
+ /// Unauthenticated, like the transparency log routes and for the same
+ /// reason: it discloses a public key's address, which was already public,
+ /// and a directory only this relay's own users can read is not one a
+ /// federated peer can use.
+ ///
+ /// An account that has not published a key answers 404 rather than a null
+ /// address, so a caller cannot tell "no such person" from "this person has
+ /// not enrolled" — the two are the same non-answer and collapsing them
+ /// keeps this from being an account-enumeration oracle.
+ func resolveIdentity(_ request: Request, handle: String) async throws -> Response {
+ let normalized = User.normalize(handle)
+ guard let user = try await User.query(on: request.db)
+ .filter(\.$username == normalized).first(),
+ let fnid = user.fnid
+ else {
+ throw WireError.notFound("no such identity")
+ }
+ return try .json(ResolveIdentityResponse(
+ handle: normalized,
+ fnid: fnid,
+ audience: config.audience))
+ }
+}
+
+// MARK: - Multi-homing
+
+extension Routes {
+
+ /// `POST /e2e/account/homes` — publish where this account keeps mailboxes.
+ ///
+ /// Accepted from any relay in the list, not only the one being written to:
+ /// the record is self-certifying, so who delivered it is irrelevant, and
+ /// requiring each relay to be told by its own user would mean a client
+ /// re-authenticating everywhere just to add a fourth.
+ func publishHomes(_ request: Request, body: Data, now: Int) async throws -> Response {
+ let caller = try await authenticate(
+ request, body: body, path: "/e2e/account/homes", now: now)
+ let input = try decodeBody(PublishHomesRequest.self, from: body)
+
+ guard let user = try await User.find(caller.userID, on: request.db),
+ let fnid = user.fnid
+ else {
+ throw WireError.badRequest("claim an account identity before publishing homes")
+ }
+ guard input.relays.count <= HomeRelays.maxHomes, !input.relays.isEmpty else {
+ throw WireError.badRequest(
+ "between 1 and \(HomeRelays.maxHomes) home relays")
+ }
+ guard input.relays.allSatisfy({ PeerDirectory.isFetchableHost($0) }) else {
+ throw WireError.badRequest("a home relay must be a routable hostname")
+ }
+ guard Set(input.relays).count == input.relays.count else {
+ throw WireError.badRequest("a relay is listed twice")
+ }
+ // The relay a record is published at must be in it. Otherwise an
+ // account could point every sender somewhere else while still holding
+ // an inbox here, and mail addressed by the record would never arrive
+ // at the box the person is actually reading.
+ guard input.relays.contains(config.audience) else {
+ throw WireError.badRequest("this relay is not in the list")
+ }
+ guard HomeRelays.verify(
+ fnid: fnid, serial: input.serial, relays: input.relays, signatureB64: input.sig)
+ else {
+ throw WireError.badRequest("the record is not signed by this account's key")
+ }
+
+ if let existing = try await HomeRecord.find(fnid, on: request.db) {
+ // Rollback is the attack a signature alone does not stop: an old
+ // list is genuinely signed, so a relay could serve one from before
+ // a second home was added and quietly become the only mailbox again.
+ guard input.serial > existing.serial else {
+ throw WireError.conflict(
+ "serial \(input.serial) does not follow \(existing.serial)")
+ }
+ existing.serial = input.serial
+ existing.relays = input.relays.joined(separator: ",")
+ existing.sig = input.sig
+ try await existing.update(on: request.db)
+ } else {
+ let row = HomeRecord()
+ row.id = fnid
+ row.serial = input.serial
+ row.relays = input.relays.joined(separator: ",")
+ row.sig = input.sig
+ try await row.create(on: request.db)
+ }
+
+ return try .json(PublishHomesResponse(
+ ok: true, fnid: fnid, serial: input.serial, relays: input.relays))
+ }
+
+ /// `GET /e2e/account/homes/:fnid` — where to deliver, and the proof.
+ ///
+ /// Unauthenticated, and returns the signature alongside the list so the
+ /// caller can check it rather than trust this relay's copy. A relay serving
+ /// a truncated list is the exact failure multi-homing exists to survive, so
+ /// the answer has to be verifiable by whoever receives it.
+ func readHomes(_ request: Request, fnid: String) async throws -> Response {
+ guard let row = try await HomeRecord.find(fnid, on: request.db) else {
+ throw WireError.notFound("no home record")
+ }
+ return try .json(HomesResponse(
+ fnid: fnid, serial: row.serial, relays: row.relayList, sig: row.sig))
+ }
+}
diff --git a/Sources/FlamenetRelay/RoutesMobile.swift b/Sources/FlamenetRelay/RoutesMobile.swift
index 95db65b..3d03081 100644
--- a/Sources/FlamenetRelay/RoutesMobile.swift
+++ b/Sources/FlamenetRelay/RoutesMobile.swift
@@ -19,7 +19,7 @@ import Vapor
///
/// That asymmetry is the right way round: `/e2e/*` carries key material and
/// sealed envelopes, and this carries the non-E2E surface that was never
-/// stronger than a WordPress Application Password anyway. It is not a reason to
+/// stronger than a the previous platform Application Password anyway. It is not a reason to
/// relax `/e2e/*` to match. Moving the app to proof-of-possession is the
/// follow-up, and until then this is exactly as strong as what it replaces —
/// no weaker, and no better.
diff --git a/Sources/FlamenetRelay/Wire.swift b/Sources/FlamenetRelay/Wire.swift
index 533eea6..40bba5d 100644
--- a/Sources/FlamenetRelay/Wire.swift
+++ b/Sources/FlamenetRelay/Wire.swift
@@ -30,6 +30,112 @@ struct WireError: Error, Content {
static func notFound(_ m: String) -> WireError { WireError("not_found", m, 404) }
static func badRequest(_ m: String) -> WireError { WireError("bad_request", m, 400) }
static func rateLimited(_ m: String) -> WireError { WireError("rate_limited", m, 429) }
+ /// The request is well-formed and authorised; the state it wants is taken.
+ /// Distinct from 400 so a client can tell "you sent me nonsense" from "that
+ /// name is spoken for", which are different things for a user to be told.
+ static func conflict(_ m: String) -> WireError { WireError("conflict", m, 409) }
+}
+
+// MARK: - Server-to-server
+
+/// Ciphertext handed over by a peer relay.
+struct FederationDepositRequest: Codable {
+ let envelopes: [Item]
+ /// Encodable too, because the relay is on both ends of this: it decodes
+ /// what a peer sent and encodes what it sends onward, and one type for both
+ /// is what stops the two directions drifting apart.
+ struct Item: Codable {
+ /// The recipient, by global address. Never a `user_id` — a row number
+ /// means nothing to the relay that sent this.
+ let to_fnid: String
+ let to_device: String
+ let payload: String
+ /// Preserved across the boundary. A message fanned out to two of the
+ /// recipient's home relays is one message, and the recipient can only
+ /// see that if the id survives the hop.
+ let envelope_id: String?
+ }
+}
+
+struct PublishHomesRequest: Decodable {
+ let serial: Int
+ let relays: [String]
+ /// Ed25519 by the account key over `fnhomes-v1|||`.
+ let sig: String
+}
+
+struct PublishHomesResponse: Content {
+ let ok: Bool
+ let fnid: String
+ let serial: Int
+ let relays: [String]
+}
+
+struct HomesResponse: Content {
+ let fnid: String
+ let serial: Int
+ let relays: [String]
+ /// Returned so a caller can verify rather than trust: a relay serving a
+ /// truncated list is what multi-homing exists to survive.
+ let sig: String
+}
+
+/// A client asking its own relay to deliver somewhere else.
+struct SendRemoteRequest: Decodable {
+ /// The recipient's global address, and the relay that homes them.
+ let to_fnid: String
+ let to_relay: String
+ let messages: [OutgoingEnvelope]
+}
+
+struct SendRemoteResponse: Content {
+ let ok: Bool
+ let accepted: Int
+ /// Which home relays took the message, and which could not be reached.
+ ///
+ /// Surfaced rather than reduced to a boolean: with several homes, "sent" is
+ /// genuinely a partial state, and a client that cannot see which copies
+ /// landed cannot decide whether to retry or to warn.
+ /// Addresses the far relay could not place. Surfaced rather than swallowed:
+ /// a client that keeps retrying an address that does not exist there will
+ /// do so forever, and only the far relay can say.
+ let unknown: [String]
+ var delivered_to: [String] = []
+ var unreachable: [String] = []
+}
+
+struct FederationDepositResponse: Content {
+ let ok: Bool
+ let accepted: Int
+ /// Addresses this relay could not place, so a peer stops retrying them.
+ /// It learns only that an fnid is not here, which it already believed.
+ let unknown: [String]
+}
+
+// MARK: - Account identity (federation)
+
+/// Claim an fnid by proving possession of the account key behind it.
+struct ClaimIdentityRequest: Decodable {
+ /// Ed25519 public key, base64, 32 raw bytes.
+ let account_key: String
+ /// Ed25519 over `FNID.claimCanonical`, by the private half of that key.
+ let sig: String
+}
+
+struct ClaimIdentityResponse: Content {
+ let ok: Bool
+ let fnid: String
+ /// False when the key was already on file, so a client repeating a call it
+ /// was unsure about can tell a fresh registration from a confirmation.
+ let created: Bool
+}
+
+struct ResolveIdentityResponse: Content {
+ let handle: String
+ let fnid: String
+ /// Which relay answered. A handle is only unique within one, so a caller
+ /// keeping this for later needs to know where it resolved.
+ let audience: String
}
// MARK: - Requests
@@ -73,7 +179,14 @@ struct SendRequest: Decodable {
let delivery_key: String?
}
-struct OutgoingEnvelope: Decodable { let to_device: String; let payload: String }
+struct OutgoingEnvelope: Decodable {
+ let to_device: String
+ let payload: String
+ /// Same value in every copy of this message sent to every home relay, so a
+ /// client polling more than one can collapse them. Optional: a client that
+ /// homes at one relay has nothing to collapse.
+ let envelope_id: String?
+}
// MARK: - Responses
@@ -86,6 +199,19 @@ struct RegisterDeviceResponse: Content {
struct BundleResponse: Content {
let user_id: Int
+ /// The target's global address, when they have claimed one.
+ ///
+ /// Needed to verify §19 inclusion: the log leaf commits to a subject, and a
+ /// client that recomputes it needs the same string the relay wrote. Null
+ /// for an account that has not enrolled, where the leaf carries the
+ /// relay-qualified fallback instead.
+ ///
+ /// A relay could lie here — and gains nothing by it. The recomputed leaf
+ /// simply fails to appear in the log, which is the failure the check
+ /// exists to produce. The stronger check, once contacts carry a pinned
+ /// fnid, is for the client to ignore this field entirely and recompute
+ /// against the address the user already accepted.
+ let fnid: String?
let devices: [Bundle]
struct Bundle: Content {
@@ -133,6 +259,10 @@ struct PollResponse: Content {
let from_user: Int
let from_device: String
let payload: String
+ /// Null for anything sent before clients carried one. A client must
+ /// treat absence as "cannot be deduplicated" and keep the message,
+ /// rather than as a value that could collide with another absence.
+ let envelope_id: String?
let created_at: String
}
}
@@ -187,9 +317,9 @@ struct UpdateMembersRequest: Decodable {
/// The group as the relay can describe it.
///
-/// Members are ids and nothing else. The PHP relay joined each id against
-/// `wp_users` to attach a `display_name`, which it could do because it *was*
-/// WordPress. This service has no user table and should not grow one: profile
+/// Members are ids and nothing else. The previous relay joined each id against
+/// its own user table to attach a `display_name`, which it could do because the
+/// two lived together. This service has no user table and should not grow one: profile
/// data staying in the identity provider is the boundary the whole split is
/// about, and a relay that caches display names is a relay that leaks them.
/// Clients resolve names against the portal, where they already do for buddy
@@ -373,7 +503,7 @@ struct CommandRequest: Decodable {
struct CommandResponse: Content { let name: String; let message: String }
-/// `GET /api/wp/v2/users/me` — WordPress's shape, because the client decodes it.
+/// `GET /api/flamenet/v1/messenger/me` — the shape the client decodes.
struct EnclaveSessionRequest: Decodable {
/// The account service's signed session token.
let token: String
@@ -458,7 +588,7 @@ extension Data {
/// What this relay serves, as a document a client can read.
///
-/// Modelled on WordPress's namespace index because the shipped client was
+/// A namespace index, because the shipped client was
/// written against that one: it fetches `/flamenet/v1` and looks for a
/// route it needs. The point is the routes that are ABSENT — a relay speaks the
/// protocol and nothing else, so the buddy-command surface is missing here and
@@ -472,6 +602,8 @@ struct NamespaceIndex: Content {
static let current = NamespaceIndex(
namespace: "flamenet/v1",
routes: [
+ "/flamenet/v1/messenger/me",
+ "/flamenet/v1/messenger/users",
"/flamenet/v1/messenger/signup",
"/flamenet/v1/messenger/signin",
"/flamenet/v1/messenger/session",
diff --git a/deploy/test-fleet/docker-compose.yaml b/deploy/test-fleet/docker-compose.yaml
new file mode 100644
index 0000000..bda9136
--- /dev/null
+++ b/deploy/test-fleet/docker-compose.yaml
@@ -0,0 +1,173 @@
+# Three throwaway relays, for testing what one relay cannot show.
+#
+# What this fleet proves: that an fnid is portable — the same account key
+# claimed at relay1 and relay2 yields the same address — that a claim signed
+# for one relay is refused by another, and that two independent logs name the
+# same subject for the same person. None of that is observable on a single
+# relay, which is why these exist.
+#
+# Relay-to-relay delivery now exists (`RoutesFederation`), and these three are
+# allowlisted to each other, so cross-relay delivery IS testable here. What the
+# fleet still cannot show is federation across a real network: all three sit on
+# one box behind one Traefik, which is why the peers are pinned to the edge
+# container's address below.
+#
+# ENV_FILE=.env docker compose -f test-fleet.yml up -d
+#
+# SQLite on purpose. Postgres would be three more containers on a box already
+# carrying seventy, and the relay's schema is identical on both backends — so
+# the heavier option would buy nothing this fleet needs.
+#
+# Two of the three deployment shapes live here, and which relay is which is
+# load-bearing rather than incidental:
+#
+# relay1, relay3 — their own account tables. Self-hosted, local identity.
+# relay2 — the shared account service. Self-hosted, shared identity.
+#
+# relay1 and relay3 are deliberately BOTH local: two independent logs naming the
+# same subject for the same person is only observable with two of them, and
+# three relays all sharing one account service would be one user base behind
+# three names, which is the opposite of the thing being tested.
+#
+# ⚠️ The live fleet ran with relay2 configured this way for some time before the
+# file said so — set on the box and never committed. A fleet rebuilt from git
+# came back subtly different, with the shared-identity shape silently absent and
+# the tests that depend on it failing as if the client had regressed.
+#
+# ⚠️ All three peers run on one machine, which no real federation does, and that
+# creates a problem the product does not have: a container dialling
+# `relay2.flamenet.io` resolves the box's own public address and the NAT will
+# not hairpin back to it. Traffic that leaves the host and comes straight back
+# is exactly what a single-box deployment cannot do.
+#
+# So each relay is told to find its peers at Traefik's address on the `web`
+# network. The hostname is unchanged, which matters: the federation signature
+# binds the destination audience, so the peers must still call each other by
+# their public names. Only the route is short-circuited, not the naming.
+#
+# FN_FEDERATION_TRAEFIK_IP is the edge container's address on `web`. Find it
+# with:
+# docker inspect edge-traefik-1 --format \
+# '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}'
+
+x-relay: &relay
+ image: flamenet-relay:fnid
+ restart: unless-stopped
+ environment: &relay-env
+ FN_HOST: "0.0.0.0"
+ FN_LOG_LEVEL: "${FN_LOG_LEVEL:-error}"
+ # No FN_POSTGRES_URL: SQLite, one file per relay on its own volume.
+ FN_DB: "/data/relay.sqlite"
+ FN_ATTACHMENTS_DIR: "/data/attachments"
+ networks: [web]
+
+services:
+ relay1:
+ <<: *relay
+ environment:
+ <<: *relay-env
+ FN_RELAY_AUDIENCE: "relay1.flamenet.io"
+ # allowlist, not open: the fleet federates with itself and nothing else,
+ # which is also the mode most self-hosters actually want.
+ FN_FEDERATION: "allowlist"
+ FN_FEDERATION_PEERS: "relay2.flamenet.io,relay3.flamenet.io"
+ FN_FEDERATION_KEY: "${R1_FEDERATION_KEY:?set in .env}"
+ FN_ISSUER_PUBLIC_KEY: "${R1_ISSUER_PUBLIC_KEY:?set in .env}"
+ FN_ISSUER_PRIVATE_KEY: "${R1_ISSUER_PRIVATE_KEY:?set in .env}"
+ FN_AT_REST_SECRET: "${R1_AT_REST_SECRET:?set in .env}"
+ volumes: [relay1-data:/data]
+ # See the note at the top: single-box federation cannot hairpin.
+ extra_hosts:
+ - "relay2.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ - "relay3.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: "web"
+ traefik.http.routers.fntest-relay1.rule: "Host(`relay1.flamenet.io`)"
+ traefik.http.routers.fntest-relay1.entrypoints: "websecure"
+ traefik.http.routers.fntest-relay1.tls.certresolver: "le"
+ traefik.http.routers.fntest-relay1.service: "fntest-relay1"
+ traefik.http.services.fntest-relay1.loadbalancer.server.port: "8080"
+
+ relay2:
+ <<: *relay
+ environment:
+ <<: *relay-env
+ FN_RELAY_AUDIENCE: "relay2.flamenet.io"
+ # allowlist, not open: the fleet federates with itself and nothing else,
+ # which is also the mode most self-hosters actually want.
+ FN_FEDERATION: "allowlist"
+ FN_FEDERATION_PEERS: "relay1.flamenet.io,relay3.flamenet.io"
+ FN_FEDERATION_KEY: "${R2_FEDERATION_KEY:?set in .env}"
+ FN_ISSUER_PUBLIC_KEY: "${R2_ISSUER_PUBLIC_KEY:?set in .env}"
+ FN_ISSUER_PRIVATE_KEY: "${R2_ISSUER_PRIVATE_KEY:?set in .env}"
+ FN_AT_REST_SECRET: "${R2_AT_REST_SECRET:?set in .env}"
+ # 🔴 **relay2 is the "self-hosted, shared identity" shape, and it is the
+ # only one of the three that is.** Setting this is what makes it advertise
+ # `accounts: shared`, and that is the whole reason this relay exists: it is
+ # somebody else's relay keeping its accounts in the shared service, which
+ # can never be on a list compiled into the app and is therefore reachable
+ # only through discovery. `LiveSignUpUITests.testSignUpOnASharedSelfHosted
+ # Relay` and `LiveSignUpFleetTest.signUpOnASharedSelfHostedRelay` both
+ # point here.
+ #
+ # ⚠️ **Removing this does not break those tests loudly.** relay2 falls back
+ # to its own account table, the app correctly starts asking for an email,
+ # and the assertion that fires says discovery did not land — which reads
+ # as a client bug when it is this line going missing.
+ FN_ENCLAVE_PUBLIC_KEY: "${R2_ENCLAVE_PUBLIC_KEY:?set in .env}"
+ # Defaults to accounts.flamenet.io. Named anyway, because the shape being
+ # tested is "a relay naming its own account service" and leaving it
+ # implicit tests the default instead.
+ FN_ACCOUNTS_URL: "${R2_ACCOUNTS_URL:-https://accounts.flamenet.io}"
+ volumes: [relay2-data:/data]
+ # See the note at the top: single-box federation cannot hairpin.
+ extra_hosts:
+ - "relay1.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ - "relay3.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: "web"
+ traefik.http.routers.fntest-relay2.rule: "Host(`relay2.flamenet.io`)"
+ traefik.http.routers.fntest-relay2.entrypoints: "websecure"
+ traefik.http.routers.fntest-relay2.tls.certresolver: "le"
+ traefik.http.routers.fntest-relay2.service: "fntest-relay2"
+ traefik.http.services.fntest-relay2.loadbalancer.server.port: "8080"
+
+ relay3:
+ <<: *relay
+ environment:
+ <<: *relay-env
+ FN_RELAY_AUDIENCE: "relay3.flamenet.io"
+ # allowlist, not open: the fleet federates with itself and nothing else,
+ # which is also the mode most self-hosters actually want.
+ FN_FEDERATION: "allowlist"
+ FN_FEDERATION_PEERS: "relay1.flamenet.io,relay2.flamenet.io"
+ FN_FEDERATION_KEY: "${R3_FEDERATION_KEY:?set in .env}"
+ FN_ISSUER_PUBLIC_KEY: "${R3_ISSUER_PUBLIC_KEY:?set in .env}"
+ FN_ISSUER_PRIVATE_KEY: "${R3_ISSUER_PRIVATE_KEY:?set in .env}"
+ FN_AT_REST_SECRET: "${R3_AT_REST_SECRET:?set in .env}"
+ volumes: [relay3-data:/data]
+ # See the note at the top: single-box federation cannot hairpin.
+ extra_hosts:
+ - "relay1.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ - "relay2.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}"
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: "web"
+ traefik.http.routers.fntest-relay3.rule: "Host(`relay3.flamenet.io`)"
+ traefik.http.routers.fntest-relay3.entrypoints: "websecure"
+ traefik.http.routers.fntest-relay3.tls.certresolver: "le"
+ traefik.http.routers.fntest-relay3.service: "fntest-relay3"
+ traefik.http.services.fntest-relay3.loadbalancer.server.port: "8080"
+
+networks:
+ web:
+ external: true
+
+# Named for the stack, so `docker volume ls` says what they are and nothing
+# here looks dangling to a prune that must never run on this box anyway.
+volumes:
+ relay1-data:
+ relay2-data:
+ relay3-data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 140c61b..c482727 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -52,6 +52,11 @@ services:
# processor's own hosted checkout.
FN_DONATE_PROVIDER: "${FN_DONATE_PROVIDER:-}"
FN_DONATE_SECRET: "${FN_DONATE_SECRET:-}"
+ # The connected account the donation is charged on, and the publishable
+ # key the browser mounts the form with. Without the first, the same key
+ # charges the platform account and Stripe reports no error.
+ FN_DONATE_ACCOUNT: "${FN_DONATE_ACCOUNT:-}"
+ FN_DONATE_PUBLISHABLE_KEY: "${FN_DONATE_PUBLISHABLE_KEY:-}"
ports:
- "127.0.0.1:8080:8080"
volumes:
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..ca186ec
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,194 @@
+# Relay API
+
+Every route a Flamenet relay serves, what authenticates it, and what it is for.
+
+Read from `Sources/FlamenetRelay/Entrypoint.swift` rather than from memory. A
+route not in this document is not served — and a document that lists routes a
+relay does not serve is worse than no document, because it is believed. That is
+not hypothetical: the relay once published its own index under a second prefix
+it served nothing on, and every client that read it broke.
+
+Base URL is `https://`. There is **one** REST prefix, `api`.
+
+---
+
+## Discovery
+
+| Route | Auth | Purpose |
+|---|---|---|
+| `GET /.well-known/flamenet/relay` | none | What this relay is |
+| `GET /api/flamenet/v1` | none | The route index |
+| `GET /e2e/log/head` | none | Signed transparency-log head |
+
+`/.well-known/flamenet/relay` is what a client reads **before sending any
+credential**:
+
+```json
+{
+ "audience": "relay.example.com",
+ "issuerPublicKey": "",
+ "protocolVersions": [1, 2, 3],
+ "keyLogHead": "https://relay.example.com/e2e/log/head",
+ "federation": "closed",
+ "federationKey": null,
+ "accounts": "local",
+ "accountsURL": null
+}
+```
+
+⚠️ **`accounts` decides where a passphrase goes.** `local` means this relay is
+its own identity provider and authenticates the password itself. `shared` means
+accounts live in a separate service named by `accountsURL`, the passphrase is
+stretched on the device, and only a derived secret is sent — to that service,
+never to the relay.
+
+⚠️ **A client must not believe `accountsURL` blindly.** It should accept only
+account services it already trusts, and treat anything else as `local`.
+Otherwise an operator could name a host they control and collect
+passphrase-derived secrets. The relay's own code pins its `accountsURL` as a
+constant for the same reason.
+
+Everything else is unauthenticated on purpose: a descriptor only this relay's
+users could read would be useless to the peers it exists to inform, and a
+transparency log only its own users could audit would not be a transparency log.
+
+---
+
+## Accounts
+
+Present only when this relay runs the built-in identity provider
+(`FN_ISSUER_PRIVATE_KEY` set).
+
+| Route | Auth | Purpose |
+|---|---|---|
+| `POST /account/signup` | none | Create an account |
+| `POST /account/signin` | none | Exchange a password for a credential |
+| `POST /account/token` | Basic | Mint a device-scoped relay token |
+| `POST /account/reset/request` | none | Ask for a reset code |
+| `POST /account/reset/confirm` | none | Redeem a reset code |
+
+Also mounted under `/api/flamenet/v1/messenger/{signup,signin}`.
+
+`signup` takes `{username, email, password, display_name?}` and answers:
+
+```json
+{ "user_id": 7, "username": "alice", "display_name": "Alice",
+ "app_password": "" }
+```
+
+⚠️ **The credential is returned once and only its hash is kept.** Losing it
+means signing in again. It is not the password, and changing the password
+invalidates it — which is what changing a password should do.
+
+⚠️ **`signin` accepts a username *or* an email.** The field has always been
+labelled "username or email"; a client that sends only usernames rejects half
+the people who typed exactly what they were asked for.
+
+⚠️ **Reset answers identically whatever the truth is** — unknown handle, no
+address on file, an account that authenticates elsewhere. A client cannot report
+whether it worked, and a UI that claims it can is lying.
+
+### Shared identity
+
+| Route | Auth | Purpose |
+|---|---|---|
+| `POST /api/flamenet/v1/messenger/session` | none | Exchange an account-service token for a relay credential |
+
+Present only when `FN_ENCLAVE_JWT_SECRET` or `FN_ENCLAVE_PUBLIC_KEY` is set.
+Takes `{token, handle, display_name?}`.
+
+⚠️ **The handle travels beside the token, not inside it.** The account service
+will not put a handle in a token so that a relay cannot correlate its users with
+another product's; the token carries a product-scoped pseudonym. The relay uses
+the handle only the first time it sees that pseudonym — after that the pseudonym
+decides, and honouring the handle would let somebody rename themselves into an
+existing account.
+
+---
+
+## Protocol routes (§8)
+
+All under `/api/flamenet/v1/e2e/…`, and also at `/e2e/…`.
+
+⚠️ **These refuse the account credential.** They take a short-lived token scoped
+to **one device and one relay**, plus a per-request signature proving possession
+of that device's identity key. A credential good for every route on every device
+would mean a token lifted from one device drains another's prekey pool and reads
+its inbox.
+
+| Route | Purpose |
+|---|---|
+| `POST /e2e/devices` | Publish a device's identity and prekeys |
+| `GET /e2e/devices/{user}` | List a user's devices — costs no prekey |
+| `GET /e2e/keys/{user}` | Fetch bundles — **pops a one-time prekey per device** |
+| `POST /e2e/prekeys` | Top the pool back up |
+| `POST /e2e/messages` | Deposit envelopes |
+| `GET /e2e/messages` | Poll this device's inbox |
+| `POST /e2e/delivery-key` | Publish a delivery-key digest for sealed sending |
+| `POST /e2e/attachments` | Upload one encrypted blob |
+| `GET /e2e/attachments/{id}` | Download it |
+| `POST /e2e/groups` | Create a group |
+| `GET /e2e/groups` | List groups |
+| `POST /e2e/groups/{gid}/members` | Add or remove members |
+| `POST /e2e/groups/{gid}/leave` | Leave |
+| `GET /e2e/turn` | TURN credentials for calls |
+| `GET /e2e/log/{head,inclusion,consistency,device/{id}}` | Transparency log |
+
+### The three that bite
+
+**`GET /e2e/keys/{user}` consumes a one-time prekey per device, every call.**
+Use `/e2e/devices/{user}` to look at who somebody is; use `keys` only when
+actually starting a session. Polling it drains the pool and pushes every peer
+onto the last-resort key, costing initial forward secrecy for everyone.
+
+**`POST /e2e/messages` names the recipient once, at the top** — not per
+envelope:
+
+```json
+{ "to": 42, "from_device": "…",
+ "messages": [ { "to_device": "…", "payload": "" } ] }
+```
+
+One envelope per device of theirs. The relay does no fan-out and no
+re-encryption; it moves opaque payloads.
+
+**`POST /e2e/devices` publishes prekeys under `prekeys`, not `opks`.** A
+registration carrying the wrong name is accepted with a `200` and stores
+nothing — the device is published and can never be started with, which is a
+failure with no error anywhere.
+
+### The per-request proof
+
+Each authenticated request carries `X-FN-Timestamp`, `X-FN-Nonce` and
+`X-FN-Signature`, an Ed25519 signature over:
+
+```
+METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body))
+```
+
+⚠️ **`LOGICAL_PATH` is the `/e2e/…` path, not the URL's path.** These routes are
+mounted twice and signed against the former; signing the REST-prefixed path
+produces a valid signature over the wrong string and a refusal with no hint why.
+
+⚠️ **The nonce is not decoration.** Without it, two inbox polls in the same
+second with the same empty body sign identically, and the replay cache cannot
+tell an honest repeat from a captured one — so it refuses the honest client.
+
+---
+
+## Federation
+
+Off by default. When `FN_FEDERATION` is not `closed`:
+
+| Route | Auth | Purpose |
+|---|---|---|
+| `POST /fed/v1/deposit` | `fnfed-v1` | Accept envelopes from a peer relay |
+| `GET /fed/v1/keys/{fnid}` | `fnfed-v1` | Serve bundles for a local account |
+
+Signed with the relay's **federation key**, which is deliberately not the issuer
+key: a key that only ever says "this relay said this" should not also be able to
+mint a token for any user on it.
+
+⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had
+traffic for it. If your relay exists so one group can talk without anyone else
+involved, `closed` is the correct answer, not the cautious one.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..a27dc1c
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,151 @@
+# Configuring a relay
+
+Every variable the relay reads, what it breaks if it is wrong, and which of the
+three deployment shapes needs it.
+
+Read from `Sources/FlamenetRelay/Config.swift` and `Entrypoint.swift` rather
+than from memory. If a variable is not in this file it is not read.
+
+---
+
+## The three shapes
+
+A relay is one of these. Nothing needs choosing beyond the first column — the
+default is a complete, self-contained relay.
+
+| | Accounts live | Federates | Who runs it |
+|---|---|---|---|
+| **Managed** | The shared FlameNet account service | Operator's choice | Us |
+| **Self-hosted, shared identity** | The shared FlameNet account service | Operator's choice | You |
+| **Self-hosted, local** *(default)* | This relay's own database | Operator's choice | You |
+
+**A local relay is not a lesser one.** Users register, exchange keys, message
+each other, get a portable address, and verify the transparency log exactly as
+anywhere else. What the shared account service adds is one credential across
+Flamenet Messenger and Flamenet Files — nothing more.
+
+---
+
+## Required
+
+Without these the relay refuses to start. That is deliberate: a relay that
+starts with a missing security parameter is worse than one that does not start,
+because nobody finds out until it matters.
+
+| Variable | Notes |
+|---|---|
+| `FN_RELAY_AUDIENCE` | The hostname this relay is addressed by, e.g. `relay.example.com`. Tokens carry it and the relay checks it, so a mismatch rejects **every** token with no other symptom. A relay reachable at several names still has exactly one of these. |
+| `FN_ISSUER_PUBLIC_KEY` | Base64 Ed25519 public key of whatever mints this relay's tokens. Never fetched over the network. |
+| `FN_AT_REST_SECRET` | Encrypts stored bodies for conversations with encryption turned off. Defends against a stolen database dump. It does not defend against you — you hold the key. |
+
+## Identity — minting your own tokens
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_ISSUER_PRIVATE_KEY` | unset | Base64 Ed25519 **seed**. Set it and this relay issues its own tokens and serves `/account/*`. Unset and those routes do not exist at all, rather than existing and refusing. |
+| `FN_TOKEN_LIFETIME` | `300` | Seconds. Short because there is no revocation list — expiry is the only thing that stops a leaked token. |
+| `FN_CLOCK_SKEW` | `60` | Seconds of tolerance on a proof timestamp. |
+
+⚠️ In one process, a relay compromise **is** an issuer compromise: the signing
+key is in the same address space. That trade is for self-hosters, who are
+running one box either way.
+
+## Storage
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_POSTGRES_URL` | unset | Set it and Postgres is used. |
+| `FN_DB` | `relay.sqlite` | SQLite file, used when the above is unset. **The schema is identical on both** — SQLite is not a degraded mode, and is the right choice for a relay serving a household or a company. |
+| `FN_ATTACHMENTS_DIR` | `attachments` | Blobs are files, not rows, in both modes. |
+
+## Network
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_HOST` | `127.0.0.1` | Not reachable from outside the host as shipped. Terminate TLS in front of it. |
+| `FN_PORT` | `8080` | |
+| `FN_LOG_LEVEL` | `error` | ⚠️ Never raise to `debug` on a machine with real accounts: it logs request URLs, and a protocol URL contains a device id. |
+
+⚠️ **HTTPS is not optional in practice.** Clients hold key material, and serving
+them over plain HTTP hands it to anyone on the path. Forward the client address
+too — sealed submissions are metered per recipient and per IP, and behind a
+proxy that does not set `X-Forwarded-For` every request looks like the proxy.
+
+## Federation
+
+Off by default, and the default is load-bearing: a relay run for one household
+should stand alone without configuring anything.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_FEDERATION` | `closed` | `closed`, `allowlist`, or `open`. An unrecognised value falls back to `closed` rather than refusing to boot — a typo in an optional variable should not stop a relay serving its own users. |
+| `FN_FEDERATION_PEERS` | empty | Comma-separated hostnames, used only under `allowlist`. Enforced **outbound as well as inbound**, from the same rule. |
+| `FN_FEDERATION_KEY` | unset | Base64 Ed25519 seed. **Required** when `FN_FEDERATION` is not `closed` — the relay refuses to boot otherwise, rather than starting unreachable and letting a stranger discover it. |
+
+`FN_FEDERATION_KEY` is deliberately **not** the issuer key. A key that only ever
+needs to say "this relay said this" should not also be able to mint a token for
+any user on it.
+
+⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had
+traffic for it, so a slice of your users' social graph is spread across
+operators you do not run. That is the honest reason this is off by default. If
+your relay exists so one group can talk without anyone else involved, `closed`
+is the correct answer, not the cautious one.
+
+✅ **A closed relay opens no outbound connections at all.** The only outbound
+code in the relay is the SMTP client below, itself disabled unless configured.
+No telemetry, no update check, no directory to register with.
+
+## Shared FlameNet accounts
+
+Only for a relay that accepts identities from a FlameNet account service.
+Leaving all of these unset gives you a relay with its own user table.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_ENCLAVE_PUBLIC_KEY` | unset | Base64 Ed25519 public key from the account service's `/.well-known/jwks.json` (the `x` member, base64url-decoded). **This is the one to use.** |
+| `FN_ENCLAVE_JWT_SECRET` | unset | The older shared HMAC secret. Works, and is why third-party relays were never possible: the secret that checks a token also mints one, so anything holding it can impersonate any account. |
+| `FN_ACCOUNTS_URL` | `https://accounts.flamenet.io` | Which account service to advertise. Point it at your own if you run one. |
+
+⚠️ **Configured, not fetched.** This decides who may assert an identity at your
+relay; a relay that learned it over the network could be pointed at a different
+issuer by whoever controls DNS. Copy it in once.
+
+⚠️ **Set the public key and HMAC tokens are refused.** That is intentional — a
+fallback would let anyone who ever learned the retired secret keep minting
+sessions forever. Migrate the account service first, then the relays.
+
+## Limits
+
+Defaults are sane; raise them only with a reason.
+
+| Variable | Default | Guards |
+|---|---|---|
+| `FN_BUNDLE_RATE` | `120`/hr | Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
+| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone — that would let one abuser make a popular account unstartable for everyone. |
+| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. |
+| `FN_ATTACHMENT_RATE` | `60`/hr | |
+| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |
+| `FN_ATTACHMENT_MAX_BYTES` | `10 MiB` | Per blob. |
+
+## Retention
+
+The relay purges on its own timer, in-process. Nothing external is needed.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `FN_DELIVERED_RETENTION_DAYS` | `1` | |
+| `FN_UNDELIVERED_RETENTION_DAYS` | `30` | ⚠️ Must stay below the client's retired-signed-prekey retention of 45 days. |
+| `FN_ATTACHMENT_RETENTION_DAYS` | `30` | |
+| `FN_MESSAGE_RETENTION_DAYS` | `90` | Plaintext-mode messages only. |
+| `FN_READ_MESSAGE_RETENTION_DAYS` | `30` | |
+
+## Mail, calls, donations
+
+All optional. Each is absent rather than half-enabled when unconfigured.
+
+| Variable | Notes |
+|---|---|
+| `FN_SMTP_HOST`, `FN_SMTP_PORT`, `FN_SMTP_FROM`, `FN_SMTP_TLS`, `FN_SMTP_USERNAME` | Password-reset mail. Unset disables reset entirely — a flow that mints a token it cannot deliver leaves the account locked out *and* a live token in the database. |
+| `FN_TURN_HOST`, `FN_TURN_SECRET`, `FN_TURN_TTL` | Voice calls. |
+| `FN_DONATE_PROVIDER`, `FN_DONATE_SECRET` | Off unless set. A relay somebody else runs has no business soliciting money for us. |
diff --git a/docs/HOSTING.md b/docs/HOSTING.md
new file mode 100644
index 0000000..34786b1
--- /dev/null
+++ b/docs/HOSTING.md
@@ -0,0 +1,141 @@
+# Running your own relay
+
+A relay is one container and one database. It needs no account service, no
+federation, and no connection to us — a relay serving one household is the
+default configuration, not a stripped-down one.
+
+⚠️ **Flamenet Files is optional.** If you host only Messenger, nothing is
+missing: your relay keeps its own accounts and everything works. Files matters
+only if you want one credential across both products — see *Three shapes*
+below.
+
+---
+
+## The shortest thing that works
+
+```bash
+mkdir -p /srv/flamenet && cd /srv/flamenet
+
+# The issuer keypair. This mints your users' session tokens; losing it signs
+# everybody out, and leaking it lets somebody mint a token for any account.
+docker run --rm flamenet-relay:latest generate-issuer-key > issuer.pem
+
+cat > .env <<'ENV'
+FN_RELAY_AUDIENCE=relay.example.com
+FN_ISSUER_PRIVATE_KEY=
+FN_ISSUER_PUBLIC_KEY=
+FN_AT_REST_SECRET=<32+ random bytes>
+FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet
+ENV
+
+docker compose up -d
+```
+
+Then check it is what you think it is:
+
+```bash
+curl https://relay.example.com/.well-known/flamenet/relay
+```
+
+⚠️ **`FN_RELAY_AUDIENCE` must be the hostname clients dial.** Tokens carry it
+and the relay checks it, so a mismatch rejects **every** token with no other
+symptom. A relay reachable at several names still has exactly one of these.
+
+⚠️ **Terminate TLS in front of it and forward the client address.** Clients hold
+key material, and plain HTTP hands it to anyone on the path. Sealed submissions
+are metered per recipient and per IP; behind a proxy that does not set
+`X-Forwarded-For`, every request looks like the proxy and that limit is theatre.
+
+Every variable is in [Configuration](/configuration).
+
+---
+
+## Three shapes
+
+| | Accounts live | Who runs it | Files needed |
+|---|---|---|---|
+| **Managed** | Shared FlameNet service | Us | no |
+| **Self-hosted, shared identity** | Shared FlameNet service | You | no |
+| **Self-hosted, local** *(default)* | Your relay's database | You | no |
+
+The third is the default and is not a lesser mode. Users register, exchange
+keys, message each other, get a portable address and verify the transparency log
+exactly as anywhere else. What the shared account service adds is **one
+credential across Flamenet Messenger and Flamenet Files** — nothing else.
+
+To adopt the shared service, set `FN_ENCLAVE_PUBLIC_KEY` to the account
+service's Ed25519 key. Your relay then advertises `accounts: shared` in its
+descriptor, and clients pick that up automatically.
+
+⚠️ **Clients accept a named account service only if they already trust it.** A
+relay claiming a service the client does not know is treated as local. That is
+deliberate: without it, an operator could point their users' passphrase-derived
+secrets at a host they control.
+
+⚠️ **Set the public key and HMAC tokens are refused.** A fallback would let
+anyone who ever learned the retired shared secret keep minting sessions forever.
+Migrate the account service first, then the relays.
+
+---
+
+## Federation, if you want it
+
+Off by default, and the default is load-bearing.
+
+```
+FN_FEDERATION=allowlist
+FN_FEDERATION_PEERS=relay.friend.example
+FN_FEDERATION_KEY=
+```
+
+⚠️ **A closed relay opens no outbound connections at all.** No telemetry, no
+update check, no directory to register with. The only outbound code is the SMTP
+client, itself disabled unless configured.
+
+⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had
+traffic for it, so a slice of your users' social graph is spread across
+operators you do not run. If your relay exists so one group can talk without
+anyone else involved, `closed` is the correct answer.
+
+`FN_FEDERATION_KEY` is deliberately not the issuer key: a key that only says
+"this relay said this" should not also mint tokens for your users.
+
+---
+
+## Verifying what you are running
+
+A relay you cannot check is a relay you are trusting, which is the thing this
+project exists to avoid.
+
+```bash
+# What it claims to be
+curl https://relay.example.com/.well-known/flamenet/relay
+
+# The signed transparency-log head
+curl https://relay.example.com/e2e/log/head
+
+# An inclusion proof, checked against a head fetched separately —
+# never against the root the proof response carries
+curl "https://relay.example.com/e2e/log/inclusion?index=0&size="
+```
+
+⚠️ **Check a proof against a head you fetched separately.** A server that
+supplies both halves of a comparison has proven nothing.
+
+---
+
+## What a relay operator can and cannot see
+
+Cannot: message contents, attachment contents, group message contents. Those are
+end-to-end encrypted and the keys never reach the relay.
+
+Can: who has an account, which devices they have, that an envelope went from one
+device to another and when, and how large it was. Retention windows for all of
+it are in [Configuration](/configuration), and the relay purges on its own timer
+with nothing external required.
+
+⚠️ **`FN_AT_REST_SECRET` defends against a stolen database dump. It does not
+defend against you** — you hold the key. Tell your users that plainly.
+
+⚠️ **Never raise `FN_LOG_LEVEL` to `debug` on a machine with real accounts.** It
+logs request URLs, and a protocol URL contains a device id.