The Flamenet Messenger relay

Extracted from the app repository, where it had been living beside the iOS
client and a copy of the engine. It is its own service and now its own repo.

It stores public keys and opaque ciphertext. It holds no private key, and
there is no path through it that could read an envelope: the payload goes
into a column and comes back out unread. Publishing it costs nothing,
because there is no secret in it -- the security of the system does not
depend on this code being unavailable to anyone.

Not included: the deployment notes and production compose file for the
instance the author runs. Those describe one box, not how to run this.
This commit is contained in:
Paul Hitt
2026-08-21 18:18:00 -04:00
commit 9e9efb016d
59 changed files with 11371 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.build/
*.sqlite
*.sqlite-*
Dockerfile
.dockerignore
.env
docker-compose.yml
+53
View File
@@ -0,0 +1,53 @@
# Copy to .env and fill in. Generate the three secrets with:
#
# openssl rand -hex 32 # POSTGRES_PASSWORD
# openssl rand -base64 32 # FN_AT_REST_SECRET
#
# POSTGRES_PASSWORD is hex, not base64, and that is not a style choice: it is
# interpolated into `postgres://flamenet:<password>@db:5432/flamenet`, where
# base64's `/`, `+` and `=` are all meaningful. A password containing `/` makes
# that URL invalid and the relay dies at start-up with a bare `Code=-1000` and
# the connection string echoed back at you — which reads like the database is
# unreachable rather than like the password needs escaping.
# openssl genpkey -algorithm ed25519 -out issuer.pem
# openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64 # PRIVATE
# openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64 # PUBLIC
#
# Keep issuer.pem. Losing it invalidates every token you have issued; leaking it
# lets anyone mint tokens this relay will accept.
POSTGRES_PASSWORD=
FN_AT_REST_SECRET=
FN_ISSUER_PRIVATE_KEY=
FN_ISSUER_PUBLIC_KEY=
# The name this relay answers to. Tokens are bound to it, so a token minted by
# one relay is not valid at another.
FN_RELAY_AUDIENCE=flamenet-relay
# Donations. Leave both blank and the donate page says so plainly instead of
# showing a form. `FN_DONATE_SECRET` is a Stripe secret key; it is used only to
# open a hosted Checkout session, so no card data reaches this service.
FN_DONATE_PROVIDER=
FN_DONATE_SECRET=
# Shared FlameNet accounts. Leave blank -- this is the self-hosting default and
# the right answer for almost everyone.
#
# Blank, the relay is its own identity provider: `/account/signup` and
# `/account/signin` write to the `users` table in the Postgres below, and an
# account here means nothing anywhere else. That is one service to run, and no
# dependency on anybody's infrastructure.
#
# Set, the relay ALSO accepts a signed session from the shared FlameNet account
# service, so one passphrase works across FlameNet products. It has to be the
# same secret that service signs with, and it is only useful if you run that
# service too -- there is no shared secret you can be issued for the FlameNet
# one, by design.
#
# Note the client half is not a server setting. The app decides where a
# passphrase may be sent from a list of hostnames pinned in the app itself, so a
# relay cannot nominate its own account service and collect the authentication
# secrets of everyone who signs in. Setting this on a relay the app has not
# pinned changes nothing on the client.
FN_ENCLAVE_JWT_SECRET=
+4
View File
@@ -0,0 +1,4 @@
.build/
.env
.DS_Store
*.sqlite
+48
View File
@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
# ---- build ----
FROM swift:6.1-noble AS build
WORKDIR /src
# Resolve dependencies first so source edits don't invalidate the dep layer.
COPY Package.swift Package.resolved ./
RUN swift package resolve
# Tests/ is copied because Package.swift declares a test target; without it
# SwiftPM can't locate that target's sources and fails the whole package.
COPY Sources ./Sources
COPY Tests ./Tests
RUN --mount=type=cache,target=/src/.build,sharing=locked \
swift build -c release --product flamenet-relay \
&& install -m 0755 \
"$(swift build -c release --product flamenet-relay --show-bin-path)/flamenet-relay" \
/usr/local/bin/flamenet-relay
# ---- runtime ----
FROM swift:6.1-noble-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin relay
COPY --from=build /usr/local/bin/flamenet-relay /usr/local/bin/flamenet-relay
# Vapor resolves Resources/Views (Leaf) and Public/ relative to the working
# directory, so the runtime image needs both and needs to start in /app.
WORKDIR /app
COPY Resources ./Resources
COPY Public ./Public
# SQLite DB + attachments live here; mount a volume to persist across runs.
RUN mkdir -p /data/attachments && chown -R relay:relay /data
ENV FN_DB=/data/relay.sqlite \
FN_ATTACHMENTS_DIR=/data/attachments
VOLUME ["/data"]
USER relay
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["flamenet-relay"]
CMD ["serve", "--hostname", "0.0.0.0", "--port", "8080"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Extant 2000 LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+366
View File
@@ -0,0 +1,366 @@
{
"originHash" : "c55e8d1dbb001459cc73385e2f8dd147e4a9a58a932b10d8d3a667f5d91360c0",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f",
"version" : "1.33.1"
}
},
{
"identity" : "async-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/async-kit.git",
"state" : {
"revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9",
"version" : "1.22.0"
}
},
{
"identity" : "console-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/console-kit.git",
"state" : {
"revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b",
"version" : "4.15.2"
}
},
{
"identity" : "fluent",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent.git",
"state" : {
"revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864",
"version" : "4.13.0"
}
},
{
"identity" : "fluent-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-kit.git",
"state" : {
"revision" : "ca609b2132bde05f9a2d7561e2864587c24fa7b9",
"version" : "1.57.0"
}
},
{
"identity" : "fluent-postgres-driver",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
"state" : {
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
"version" : "2.12.0"
}
},
{
"identity" : "fluent-sqlite-driver",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-sqlite-driver.git",
"state" : {
"revision" : "e227d94dc3fe7099a096ac2ca28cad1f69bd8d5b",
"version" : "4.9.0"
}
},
{
"identity" : "leaf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/leaf.git",
"state" : {
"revision" : "b70a6108e4917f338f6b8848407bf655aa7e405f",
"version" : "4.5.1"
}
},
{
"identity" : "leaf-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/leaf-kit.git",
"state" : {
"revision" : "64cc0a5c8dcf4fbd03be73b944f6850c0b82e439",
"version" : "1.14.3"
}
},
{
"identity" : "multipart-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/multipart-kit.git",
"state" : {
"revision" : "3498e60218e6003894ff95192d756e238c01f44e",
"version" : "4.7.1"
}
},
{
"identity" : "postgres-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-kit.git",
"state" : {
"revision" : "218aaf6810db9e61398f887aaaf26424142bdb53",
"version" : "2.16.1"
}
},
{
"identity" : "postgres-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-nio.git",
"state" : {
"revision" : "39ce38a93408937bcd27f521f3cdf88266136b80",
"version" : "1.33.1"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/routing-kit.git",
"state" : {
"revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0",
"version" : "4.9.3"
}
},
{
"identity" : "sql-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/sql-kit.git",
"state" : {
"revision" : "46d1a644846c6c42890a06988eb85cd975c3afc6",
"version" : "3.35.0"
}
},
{
"identity" : "sqlite-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/sqlite-kit.git",
"state" : {
"revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f",
"version" : "4.5.2"
}
},
{
"identity" : "sqlite-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/sqlite-nio.git",
"state" : {
"revision" : "f038d1e8fd858634bab5f1ffed7b6f418de833c8",
"version" : "1.12.10"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "9f542610331815e29cc3821d3b6f488db8715517",
"version" : "1.6.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c",
"version" : "1.1.5"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
"version" : "1.3.1"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25",
"version" : "1.18.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration.git",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
"version" : "3.15.1"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0",
"version" : "1.4.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b",
"version" : "1.6.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "8c0f217f01000dd30f60d6e536569ad4e74291f9",
"version" : "1.11.0"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "f17c111cec972c2a4922cef38cf64f76f7e87886",
"version" : "2.8.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
"version" : "2.97.1"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "abcf5312eb8ed2fb11916078aef7c46b06f20813",
"version" : "1.33.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "6d8d596f0a9bfebb925733003731fe2d749b7e02",
"version" : "1.42.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "df9c3406028e3297246e6e7081977a167318b692",
"version" : "2.36.1"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "60c3e187154421171721c1a38e800b390680fb5d",
"version" : "1.26.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29",
"version" : "1.3.0"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system",
"state" : {
"revision" : "0b30161977799fd949f7f6848586b82ff6764f73",
"version" : "1.6.6"
}
},
{
"identity" : "vapor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/vapor.git",
"state" : {
"revision" : "748ae8432a33e0965bbf0351fedd4e915e7f460c",
"version" : "4.122.0"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/websocket-kit.git",
"state" : {
"revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3",
"version" : "2.16.2"
}
}
],
"version" : 3
}
+41
View File
@@ -0,0 +1,41 @@
// swift-tools-version: 6.0
import PackageDescription
// Vapor for the API, Fluent for the ORM, Leaf for the landing page, and
// swift-crypto for the one cryptographic operation this service performs:
// verifying an Ed25519 signature. It holds no private key and decrypts nothing.
let package = Package(
name: "flamenet-relay",
platforms: [.macOS(.v13)],
products: [
.executable(name: "flamenet-relay", targets: ["FlamenetRelay"]),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.106.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.7.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
.package(url: "https://github.com/vapor/leaf.git", from: "4.3.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
],
targets: [
.executableTarget(
name: "FlamenetRelay",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Leaf", package: "leaf"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
.testTarget(
name: "FlamenetRelayTests",
dependencies: [
"FlamenetRelay",
.product(name: "VaporTesting", package: "vapor"),
]
),
]
)
+712
View File
@@ -0,0 +1,712 @@
/* Flamenet Messenger — site theme.
*
* Modern software-product styling: a dark hero, gradient accents, eyebrow
* labels, elevated cards. Everything is self-hosted because the relay serves
* this page under `default-src 'self'` — a CDN font or an analytics tag would
* simply not load, which is the intended failure mode rather than an
* inconvenience. Hence the system font stack: it is the only way to get a
* modern typeface without asking a third party for it.
*/
:root {
/* Light by commitment, not by default. The page does not follow the
system scheme: a single deliberate look beats two half-tuned ones, and
the gradient work below is designed against white. */
--bg: #ffffff;
--bg-2: #f7f8fc;
--bg-3: #f1f3f9;
--panel: #ffffff;
--ink: #212529;
--ink-2: #5b6472;
--ink-3: #838b99;
--border: #eaedf5;
--border-2: #dde2ee;
/* Measured from the reference design rather than guessed: violet primary
with an amber secondary, on white. */
--brand: #5842bc;
--brand-2: #7b63e8;
--brand-ink: #ffffff;
--accent: #fdb900;
--grad: linear-gradient(115deg, var(--brand) 0%, var(--brand-2) 100%);
--grad-soft: linear-gradient(115deg, rgba(88,66,188,.10), rgba(123,99,232,.08));
--radius: 16px;
--radius-lg: 22px;
--wrap: 1180px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--shadow: 0 1px 2px rgba(13,18,32,.04), 0 12px 32px rgba(13,18,32,.07);
--shadow-lg: 0 2px 4px rgba(13,18,32,.05), 0 28px 60px rgba(13,18,32,.13);
}
html { color-scheme: light; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font);
font-size: 17px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.wrap { width: 100%; max-width: var(--wrap); margin: 0 auto; padding: 0 24px; }
.wrap-narrow { max-width: 780px; }
a { color: inherit; }
.skip { position: absolute; left: -9999px; background: var(--ink); color: var(--bg); padding: 10px 16px; border-radius: 8px; }
.skip:focus { left: 16px; top: 16px; z-index: 100; }
/* ---------- nav ---------- */
.nav {
position: sticky; top: 0; z-index: 50;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-inner { display: flex; align-items: center; gap: 16px; height: 74px; }
.brand {
display: flex; align-items: center; gap: 11px;
font-weight: 700; letter-spacing: -.02em; font-size: 17px;
text-decoration: none; flex: none;
}
.mark {
width: 30px; height: 30px; border-radius: 9px; flex: none;
background: var(--grad);
box-shadow: 0 4px 12px rgba(88,66,188,.34);
}
.nav-links { display: flex; align-items: center; justify-content: center; gap: 34px; flex: 1; }
.nav-links a { text-decoration: none; color: var(--ink-2); font-size: 15px; font-weight: 550; }
.nav-links a:hover { color: var(--ink); }
/* `.nav-links a` outranks `.btn-nav` on specificity, so without this the CTA
inherits the muted link colour and renders grey on a saturated fill. */
.nav-links a.btn-nav, .nav-links a.btn-nav:hover { color: var(--brand-ink); }
/* `flex: none` matters: in the nav's flex row the button would otherwise
shrink to its padding and render three zero-width bars. */
.nav-toggle { display: none; flex: none; }
/* ---------- buttons ---------- */
.btn {
display: inline-block; text-decoration: none; font-weight: 650; font-size: 15.5px;
padding: 14px 28px; border-radius: 999px; border: 1px solid transparent;
transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
.btn-primary {
background: var(--grad); color: var(--brand-ink);
box-shadow: 0 8px 22px rgba(88,66,188,.32);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 14px 32px rgba(88,66,188,.42); }
.btn-ghost { border-color: var(--border-2); color: var(--ink); background: var(--panel); }
.btn-ghost:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.nav-cta-placeholder{}
.nav-cta { flex: none; }
.btn-nav {
background: var(--grad); color: var(--brand-ink);
padding: 10px 22px; font-size: 15px;
box-shadow: 0 6px 16px rgba(88,66,188,.3);
}
.btn-nav:hover { transform: translateY(-1px); }
/* ---------- hero ---------- */
.hero {
position: relative; overflow: hidden;
padding: 104px 0 96px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
/* Two blurred gradient fields. Pure CSS so nothing is fetched. */
.hero::before, .hero::after {
content: ""; position: absolute; border-radius: 50%;
filter: blur(90px); opacity: .5; pointer-events: none;
}
.hero::before {
/* The signature shape of this layout: one big soft curve occupying the
right half, with the product art sitting on top of it. */
width: 47%; height: 132%; top: -16%; right: 0;
border-radius: 44% 0 0 44% / 50% 0 0 50%;
background: var(--grad);
opacity: 1; filter: none;
}
.hero::after {
width: 340px; height: 340px; top: 4%; right: 10%;
background: radial-gradient(circle, rgba(255,255,255,.30), transparent 70%);
filter: blur(34px);
}
.hero-copy { max-width: 34em; }
.hero-inner {
position: relative; z-index: 1;
display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 56px; align-items: center;
}
.hero h1 {
margin: 0 0 22px;
font-size: clamp(38px, 4.6vw, 54px);
line-height: 1.14; letter-spacing: -.02em; font-weight: 750;
}
.lede { margin: 0 0 32px; font-size: 19.5px; color: var(--ink-2); max-width: 30em; }
.cta-row { display: flex; flex-wrap: wrap; gap: 14px; }
.hero-note { margin: 22px 0 0; font-size: 14px; color: var(--ink-3); }
.hero-art { display: flex; justify-content: center; }
.phone {
width: 296px; height: 590px; border-radius: 44px; padding: 13px;
background: linear-gradient(165deg, #2b3346, #0e131f);
box-shadow: var(--shadow-lg);
transform: rotate(-2deg);
}
.phone-screen {
height: 100%; border-radius: 33px; background: var(--bg-3);
padding: 24px 16px; display: flex; flex-direction: column; gap: 12px; justify-content: flex-end;
}
.bubble { max-width: 82%; padding: 11px 15px; border-radius: 18px; font-size: 14.5px; line-height: 1.45; }
.bubble.in { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bubble.out { align-self: flex-end; background: var(--grad); color: var(--brand-ink); }
/* ---------- sections ---------- */
.band { padding: 96px 0; }
.band-alt { background: var(--bg-2); }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 16px; margin-bottom: 18px;
border-radius: 999px; border: 1px solid var(--border-2);
background: var(--grad-soft); color: var(--brand);
font-size: 13px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
}
.eyebrow::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--accent);
}
/* One word of a heading carried in the accent — the two-tone treatment this
layout uses to stop a large heading reading as a wall. */
.hl {
background: var(--grad);
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
.band-center { text-align: center; }
.band-center .band-lede { margin-left: auto; margin-right: auto; }
.band-center .fineprint { margin-left: auto; margin-right: auto; }
.band h2 {
margin: 0 0 18px; font-size: clamp(28px, 3.2vw, 40px);
letter-spacing: -.02em; line-height: 1.2; font-weight: 750;
}
.band-lede { margin: 0 0 52px; color: var(--ink-2); font-size: 18.5px; max-width: 47em; }
/* ---------- cards ---------- */
/* Fixed four columns rather than auto-fit: with eight features, auto-fit
resolves to five-and-three at wide viewports and leaves a ragged row. */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 42px 30px; }
.cards-3 { grid-template-columns: repeat(3, 1fr); gap: 24px; }
.card {
background: transparent; border: 0;
border-radius: var(--radius-lg); padding: 22px 14px;
transition: transform .18s ease;
}
.card:hover { transform: translateY(-5px); }
/* The developer trio keeps its panels; only the feature row goes card-less. */
.cards-3 .card {
background: var(--panel); border: 1px solid var(--border);
padding: 32px 28px; text-align: left;
}
.cards-3 .card:hover { box-shadow: var(--shadow-lg); }
.cards-3 .card-icon { margin-left: 0; }
.card h3 { margin: 0 0 10px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
.card p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; }
.card-icon {
display: flex; align-items: center; justify-content: center;
width: 62px; height: 62px; margin: 0 auto 20px;
border-radius: 50%; background: var(--grad);
color: #fff;
box-shadow: 0 10px 24px rgba(88,66,188,.28);
}
.card-icon svg { width: 27px; height: 27px; display: block; }
/* Each tile takes its own hue, as the demo does — the set reads as a family
rather than five copies of one badge. */
.card:nth-child(5n+2) .card-icon { background: linear-gradient(115deg,#fdb900,#ffd257); box-shadow: 0 10px 24px rgba(253,185,0,.30); }
.card:nth-child(5n+3) .card-icon { background: linear-gradient(115deg,#2e99f5,#69bcff); box-shadow: 0 10px 24px rgba(46,153,245,.28); }
.card:nth-child(5n+4) .card-icon { background: linear-gradient(115deg,#198754,#31c37e); box-shadow: 0 10px 24px rgba(25,135,84,.26); }
.card:nth-child(5n+5) .card-icon { background: linear-gradient(115deg,#e0457b,#f57fa8); box-shadow: 0 10px 24px rgba(224,69,123,.26); }
.cards .card { text-align: center; }
.cards .card p { margin-left: auto; margin-right: auto; max-width: 34em; }
.cards .card-link { margin-top: 16px; }
.card-link {
display: inline-block; margin-top: 15px; font-size: 14.5px;
font-weight: 650; color: var(--brand); text-decoration: none;
}
.card-link:hover { text-decoration: underline; }
/* ---------- social-proof band ---------- */
.proof-band { padding: 44px 0 40px; background: var(--bg); border-bottom: 1px solid var(--border); }
.proof-lead {
margin: 0 0 22px; text-align: center;
color: var(--ink-3); font-size: 15px; font-weight: 600;
}
.proof-lead strong { color: var(--ink); font-weight: 700; }
.proof {
display: flex; flex-wrap: wrap; justify-content: center;
align-items: center; gap: 18px 54px;
}
.proof span { color: var(--ink-3); font-size: 15px; font-weight: 600; white-space: nowrap; }
.proof strong { color: var(--ink); font-weight: 750; }
/* ---------- steps ---------- */
.steps {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 26px;
}
.step {
position: relative;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 32px 28px;
transition: transform .18s ease, box-shadow .18s ease;
}
.step:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.step-n {
display: inline-flex; align-items: center; justify-content: center;
width: 42px; height: 42px; border-radius: 12px;
background: var(--grad); color: var(--brand-ink);
font-weight: 750; font-size: 16px; margin-bottom: 16px;
box-shadow: 0 6px 16px rgba(88,66,188,.3);
}
.step h3 { margin: 0 0 9px; font-size: 18.5px; letter-spacing: -.015em; }
.step p { margin: 0; color: var(--ink-2); font-size: 15.5px; }
.step .card-icon { display: none; }
/* ---------- self-hosting split ---------- */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; }
.code {
margin: 0; padding: 26px; overflow-x: auto;
background: #0c111c; color: #e7ebf5;
border: 1px solid var(--border-2); border-radius: var(--radius);
font-family: var(--mono); font-size: 14px; line-height: 1.75;
box-shadow: var(--shadow-lg);
}
.code code { white-space: pre; }
.fineprint {
margin: 34px 0 0; font-size: 14.5px; color: var(--ink-3);
max-width: 54em; padding-top: 22px; border-top: 1px solid var(--border);
}
.fineprint code { font-family: var(--mono); font-size: 13.5px; }
.fineprint-flush { border-top: 0; padding-top: 0; margin-top: 20px; }
/* ---------- platforms ---------- */
.platforms { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.platform {
display: flex; flex-direction: column; gap: 5px;
padding: 26px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--panel);
text-decoration: none;
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.platform:hover { transform: translateY(-4px); border-color: var(--brand); box-shadow: var(--shadow); }
.platform-name { font-weight: 700; font-size: 16.5px; }
.platform-state { font-size: 14px; color: var(--ink-3); }
.platform-soon { opacity: .6; }
/* ---------- faq ---------- */
.faq details {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 22px 26px; margin-bottom: 14px;
transition: border-color .18s ease;
}
.faq details[open] { border-color: var(--border-2); box-shadow: var(--shadow); }
.faq summary {
cursor: pointer; font-weight: 660; font-size: 17.5px;
list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 16px;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; font-size: 24px; color: var(--brand); font-weight: 400; line-height: 1; }
.faq details[open] summary::after { content: "\2212"; }
.faq details p { margin: 14px 0 0; color: var(--ink-2); max-width: 60em; }
/* ---------- footer ---------- */
.footer { padding: 76px 0 52px; background: var(--bg-2); border-top: 1px solid var(--border); }
.footer-cols { display: grid; grid-template-columns: 1.5fr repeat(4, 1fr); gap: 34px; margin-bottom: 44px; }
.footer-brand { display: flex; align-items: center; gap: 11px; font-weight: 700; letter-spacing: -.02em; }
.footer-col { display: flex; flex-direction: column; gap: 11px; }
.footer-col h4 {
margin: 0 0 5px; font-size: 12.5px; text-transform: uppercase;
letter-spacing: .1em; color: var(--ink-3); font-weight: 700;
}
.footer-col a { text-decoration: none; color: var(--ink-2); font-size: 15px; }
.footer-col a:hover { color: var(--brand); }
.footer-legal {
margin: 0; padding-top: 30px; border-top: 1px solid var(--border);
font-size: 13.5px; color: var(--ink-3); max-width: 62em;
}
/* ---------- document pages ---------- */
.doc { padding-top: 72px; }
.doc h1 { margin: 0 0 20px; font-size: clamp(36px, 4.8vw, 50px); letter-spacing: -.032em; line-height: 1.08; font-weight: 800; }
.doc h2 { margin: 48px 0 14px; font-size: 23px; letter-spacing: -.02em; font-weight: 700; }
.doc p { margin: 0 0 17px; color: var(--ink-2); }
.doc .lede { font-size: 19.5px; margin-bottom: 10px; }
.doc .meta { font-size: 14.5px; color: var(--ink-3); margin-bottom: 10px; }
.doc ul, .doc ol { margin: 0 0 17px; padding-left: 22px; color: var(--ink-2); }
.doc li { margin-bottom: 9px; }
.doc a { color: var(--brand); text-decoration: none; }
.doc a:hover { text-decoration: underline; }
.doc strong { color: var(--ink); }
.doc table { width: 100%; border-collapse: collapse; margin: 0 0 22px; font-size: 15px; display: block; overflow-x: auto; }
.doc th, .doc td { border: 1px solid var(--border); padding: 10px 14px; text-align: left; vertical-align: top; }
.doc th { background: var(--bg-2); font-weight: 680; white-space: nowrap; }
.doc td { color: var(--ink-2); }
.code-light { font-size: 12.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
/* ---------- generated documentation ---------- */
.doc-generated h3 { margin: 30px 0 11px; font-size: 18.5px; font-weight: 680; letter-spacing: -.015em; }
.doc-generated h4 { margin: 24px 0 9px; font-size: 16.5px; font-weight: 680; }
.doc-generated hr { border: 0; border-top: 1px solid var(--border); margin: 44px 0; }
.doc-generated code {
background: var(--bg-3); border: 1px solid var(--border);
border-radius: 6px; padding: 1.5px 6px;
font-family: var(--mono); font-size: .87em; color: var(--ink);
}
.doc-generated pre {
background: #0c111c; color: #e7ebf5; border-radius: var(--radius);
padding: 22px; overflow-x: auto; margin: 0 0 20px; border: 1px solid var(--border-2);
}
.doc-generated pre code { background: none; border: 0; padding: 0; color: inherit; font-size: 13px; line-height: 1.7; white-space: pre; }
.doc-generated table { width: 100%; border-collapse: collapse; margin: 0 0 22px; font-size: 14.5px; display: block; overflow-x: auto; }
.doc-generated th, .doc-generated td { border: 1px solid var(--border); padding: 9px 13px; text-align: left; vertical-align: top; }
.doc-generated th { background: var(--bg-2); font-weight: 680; white-space: nowrap; }
.doc-generated td { color: var(--ink-2); }
.doc-generated blockquote {
margin: 0 0 20px; padding: 14px 20px;
border-left: 3px solid var(--brand); background: var(--bg-2);
border-radius: 0 10px 10px 0; color: var(--ink-2);
}
.doc-generated ol, .doc-generated ul { margin: 0 0 17px; padding-left: 22px; color: var(--ink-2); }
/* ---------- responsive ---------- */
@media (max-width: 980px) {
.hero { padding: 68px 0 60px; }
.hero-inner { grid-template-columns: 1fr; gap: 44px; }
/* Stacked, as the demo stacks it: copy first on plain ground, then the
artwork sitting on the gradient panel. */
.hero { padding-bottom: 0; }
/* The gradient panel is attached to the artwork rather than sized as a
percentage of the hero: the hero's height depends on how far the copy
wraps, and a percentage panel climbs up over the last lines of it. */
.hero-art {
display: flex; margin: 0 -24px -1px; padding: 44px 0 0;
background: var(--grad);
border-radius: 34% 34% 0 0 / 12% 12% 0 0;
}
.hero::before { display: none; }
.hero::after { display: none; }
.phone { transform: none; width: 268px; height: 520px; }
.cards, .cards-3, .steps { grid-template-columns: 1fr 1fr; }
.platforms { grid-template-columns: 1fr 1fr; }
.split { grid-template-columns: 1fr; gap: 30px; }
.footer-cols { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
body { font-size: 16px; }
.band { padding: 64px 0; }
.cards, .cards-3, .steps, .platforms, .footer-cols { grid-template-columns: 1fr; }
/* Brand, menu button and CTA have to share 375px. Left at desktop sizes the
row overflows and the CTA is clipped by the viewport edge. */
.nav-inner { gap: 8px; }
.brand { font-size: 15.5px; gap: 9px; }
.brand .mark { width: 26px; height: 26px; border-radius: 8px; }
.btn-nav { padding: 9px 18px; font-size: 14.5px; }
.nav-toggle {
display: flex; flex-direction: column; justify-content: center; gap: 5px;
width: 38px; height: 42px; padding: 0 6px; background: none; border: 0; cursor: pointer;
}
.nav-toggle span { display: block; height: 2px; background: var(--ink); border-radius: 2px; }
.nav-links {
display: none; position: absolute; left: 0; right: 0; top: 74px;
flex-direction: column; align-items: flex-start; gap: 4px;
background: var(--bg); border-bottom: 1px solid var(--border); padding: 18px 24px 24px;
}
.nav-links.open { display: flex; }
.nav-links a { padding: 11px 0; font-size: 16px; }
.btn-nav { margin-top: 10px; }
}
/* ---------- announcement bar ---------- */
.topbar {
background: var(--ink); color: #fff;
font-size: 13px; line-height: 1;
}
.topbar .wrap {
display: flex; align-items: center; justify-content: center;
gap: 10px; padding-top: 11px; padding-bottom: 11px; flex-wrap: wrap;
}
.topbar span { opacity: .8; }
.topbar-tag {
background: var(--accent); color: #241a00;
font-weight: 800; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
padding: 4px 9px; border-radius: 5px; opacity: 1;
}
.topbar a { color: #fff; text-decoration: underline; text-underline-offset: 3px; }
/* ---------- hero chips ---------- */
.hero-chips { display: flex; flex-wrap: wrap; gap: 12px 30px; margin-top: 40px; }
.chip {
display: inline-flex; align-items: center; gap: 10px;
font-size: 13.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
color: var(--ink-2);
}
.chip-ic {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 50%;
background: var(--bg-3); color: var(--brand); flex: none;
}
.chip-ic svg { width: 15px; height: 15px; }
/* ---------- community cards ---------- */
.commun { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.commun-card {
display: flex; align-items: center; gap: 18px; text-align: left;
padding: 24px 26px; border-radius: var(--radius);
background: var(--panel); border: 1px solid var(--border);
text-decoration: none;
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.commun-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); border-color: var(--brand); }
.commun-ic {
display: inline-flex; align-items: center; justify-content: center;
width: 52px; height: 52px; border-radius: 50%; flex: none;
background: var(--grad); color: #fff;
box-shadow: 0 8px 20px rgba(88,66,188,.28);
}
.commun-ic svg { width: 23px; height: 23px; }
.commun-inf { display: flex; flex-direction: column; gap: 3px; }
.commun-inf strong { font-size: 17px; font-weight: 700; color: var(--ink); }
.commun-inf small { font-size: 14px; color: var(--ink-3); }
/* ---------- footer call to action ---------- */
.footer-cta { text-align: center; padding-bottom: 56px; }
.footer-cta h2 {
margin: 0 0 12px; font-size: clamp(28px, 3.2vw, 40px);
letter-spacing: -.02em; line-height: 1.2; font-weight: 750;
}
.footer-cta p { margin: 0 auto 30px; color: var(--ink-2); max-width: 46em; }
.footer-cta-row { justify-content: center; }
@media (max-width: 980px) {
.commun { grid-template-columns: 1fr; }
}
/* ---------- donate ---------- */
.nav-cta { display: flex; align-items: center; gap: 10px; flex: none; }
.btn-outline {
background: transparent; border-color: var(--brand); color: var(--brand);
padding: 10px 22px; font-size: 15px;
}
.btn-outline:hover { background: var(--grad-soft); transform: translateY(-1px); }
/* `.nav-links a` does not reach these — they live outside that container — but
be explicit so a later markup change cannot silently mute them. */
.nav-cta a.btn-outline { color: var(--brand); }
.nav-cta a.btn-nav { color: var(--brand-ink); }
.donate-pending {
padding: 18px 22px; border-radius: var(--radius);
background: var(--bg-2); border: 1px dashed var(--border-2);
color: var(--ink-2);
}
@media (max-width: 640px) {
.nav-donate { display: none; }
}
/* ---------- donation form ---------- */
.donate-form {
margin: 0 0 24px; padding: 28px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel);
}
.donate-form fieldset { border: 0; padding: 0; margin: 0 0 22px; }
.donate-form legend {
padding: 0; margin-bottom: 12px;
font-size: 13px; font-weight: 700; letter-spacing: .07em;
text-transform: uppercase; color: var(--ink-3);
}
.donate-freq { display: flex; gap: 10px; }
.donate-amounts { display: flex; flex-wrap: wrap; gap: 10px; }
.donate-form label {
display: inline-flex; align-items: center; gap: 8px;
padding: 12px 20px; border-radius: 999px;
border: 1px solid var(--border-2); background: var(--bg);
font-weight: 650; font-size: 15.5px; cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
}
.donate-form label:hover { border-color: var(--brand); }
/* The radio itself is kept in the accessibility tree and focusable — hiding it
with display:none would take it out of both. */
.donate-form input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; }
.donate-form label:has(input[type="radio"]:checked) {
border-color: var(--brand); background: var(--grad-soft); color: var(--brand);
}
.donate-form label:has(input[type="radio"]:focus-visible) {
outline: 2px solid var(--brand); outline-offset: 2px;
}
.donate-custom input[type="number"] {
width: 90px; padding: 6px 10px; font: inherit; font-size: 15px;
border: 1px solid var(--border-2); border-radius: 8px;
background: var(--bg); color: var(--ink);
}
.donate-form .btn { border: 0; cursor: pointer; font-family: inherit; }
@media (max-width: 640px) {
.donate-freq, .donate-amounts { flex-direction: column; align-items: stretch; }
.donate-form label { justify-content: center; }
}
/* ---------- straight-talk band ---------- */
/* A centred heading over a left-aligned body: the caveats read as prose you
are meant to actually read, not as three more marketing tiles. */
.band-head { text-align: center; }
.band-head .band-lede { margin-left: auto; margin-right: auto; }
.honest { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.honest-card {
padding: 30px 28px; border-radius: var(--radius);
background: var(--panel); border: 1px solid var(--border);
border-top: 3px solid var(--accent);
}
.honest-card h3 { margin: 0 0 10px; font-size: 18px; letter-spacing: -.015em; font-weight: 700; }
.honest-card p { margin: 0; color: var(--ink-2); font-size: 15.5px; }
/* ---------- developer note ---------- */
/* Deliberately no strip of protocol acronyms here. The names live in the
specification, where someone looking for them has context; on the landing
page they read as a wall to everybody else. */
.stack-note {
max-width: 44em; margin: 40px auto 0; padding: 22px 26px;
border-radius: var(--radius); background: var(--bg-2);
border: 1px solid var(--border);
color: var(--ink-2); font-size: 15.5px;
}
/* The FAQ band is centred at the head and left-aligned in the body; without
this the flex summary would centre its text and the answers would too. */
.faq .band-center { margin-bottom: 44px; }
.faq details, .faq details p { text-align: left; }
@media (max-width: 980px) {
.honest { grid-template-columns: 1fr; }
}
/* ---------- comparison table ---------- */
/* The page names three other products, so the table has to be readable enough
that someone can actually check it rather than skim the ticks. */
.compare-scroll { overflow-x: auto; margin: 0 0 30px; -webkit-overflow-scrolling: touch; }
.compare {
width: 100%; min-width: 780px;
border-collapse: separate; border-spacing: 0;
font-size: 15px; display: table;
}
.compare th, .compare td {
border-bottom: 1px solid var(--border);
padding: 16px 18px; text-align: left; vertical-align: top;
}
.compare thead th {
position: sticky; top: 0; z-index: 1;
background: var(--bg); border-bottom: 2px solid var(--border-2);
font-size: 15px; font-weight: 700; white-space: nowrap;
}
.compare tbody th {
font-weight: 620; color: var(--ink); width: 24%;
background: var(--bg);
}
.compare td { color: var(--ink-2); width: 19%; }
/* Our own column, marked rather than disguised — a comparison written by one
of the parties should say which one it is. */
.compare .mine { background: var(--grad-soft); }
.compare thead th.mine {
background: var(--grad); color: var(--brand-ink);
border-radius: 10px 10px 0 0;
}
/* The verdict sits on its own line above its note, and the break is a <br> in
the markup rather than `display: block` here. Styling the separation looks
right on screen and copies as "YesAn opt-in plain mode also exists" — the
text has to carry the break itself. */
.compare td strong { font-weight: 680; color: var(--ink); }
.compare td small {
display: inline-block; margin-top: 5px;
font-size: 13.5px; line-height: 1.55; color: var(--ink-3); font-weight: 400;
}
.compare .yes { color: #198754; }
.compare .no { color: #b4453f; }
.compare .part { color: #b07d00; }
.callout {
margin: 0 0 34px; padding: 22px 26px;
border-radius: var(--radius); background: var(--bg-2);
border-left: 3px solid var(--accent);
}
.callout p { margin: 0; }
/* ---------- footer hosting credit ---------- */
/* The hosting credit runs on from the legal note as one more sentence. The
only outbound link on the site besides the repository. */
.footer-legal .host-link { color: var(--brand); text-decoration: none; font-weight: 650; }
.footer-legal .host-link:hover { text-decoration: underline; }
.footer-legal .heart { color: #e0457b; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 640px) {
.compare-scroll { margin-inline: -24px; padding-inline: 24px; }
}
/* The comparison page runs to the full wrap width because of the table, so its
opening paragraph should too — `.lede` caps at 30em, which leaves it sitting
in the left half of a page that is twice that wide. */
.lede-wide { max-width: none; }
+17
View File
@@ -0,0 +1,17 @@
// The nav toggle, and nothing else. This page runs under `default-src 'self'`
// and carries no analytics; keep it that way.
(function () {
var toggle = document.querySelector('.nav-toggle');
var links = document.getElementById('nav-links');
if (!toggle || !links) return;
toggle.addEventListener('click', function () {
var open = links.classList.toggle('open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
links.addEventListener('click', function (e) {
if (e.target.tagName === 'A') {
links.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
})();
+138
View File
@@ -0,0 +1,138 @@
# flamenet-relay
The `/e2e/*` half of Flamenet Messenger as a standalone service, so the message
relay is a standalone service.
It stores public keys and opaque ciphertext. It holds no private key, and there
is no path in it that could decrypt an envelope if it wanted to: `payload` goes
into a column and comes back out unread.
It **is** its own identity provider, and that is what makes self-hosting one
service rather than two. `/account/signup` and `/account/signin` write to this
relay's own database; an account here means nothing anywhere else. It mints the
short-lived, device-scoped tokens it later verifies against a **pinned** public
key it never fetches on the request path.
Optionally it will also accept a signed session from a shared FlameNet account
service, so one passphrase works across FlameNet products. That is off unless
`FN_ENCLAVE_JWT_SECRET` is set, and off is the right answer for almost everyone
— see `.env.example`.
## The rest of the project
| Repo | What it is |
|---|---|
| [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e) | The protocol: JavaScript engine, spec, test vectors |
| **flamenet-relay** | This service |
| [flamenet-messenger-ios](https://gitlab.com/paulhitt/flamenet-messenger-ios) | The iOS app |
The relay is useless on its own and says so: it is a place to leave ciphertext
for someone, and every guarantee worth having is made by the clients.
## Running it
```bash
swift build -c release
FN_ISSUER_PUBLIC_KEY=<base64 Ed25519 public key> \
FN_RELAY_AUDIENCE=https://relay.flamenet.io \
FN_DB=/var/lib/flamenet/relay.sqlite \
.build/release/flamenet-relay
```
It refuses to start without an issuer key. There is no safe default for that
value, and a relay that accepts unsigned tokens is worse than one that is down.
| Variable | Default | Notes |
|---|---|---|
| `FN_ISSUER_PUBLIC_KEY` | — | Required. Base64 Ed25519 public key of the token issuer. |
| `FN_RELAY_AUDIENCE` | — | Required. The `aud` this relay accepts. |
| `FN_DB` | `relay.sqlite` | SQLite file. |
| `FN_HOST` / `FN_PORT` | `127.0.0.1` / `8080` | Bind address. |
| `FN_WORKDIR` | inferred | Directory containing `Resources/` and `Public/`. Set it in a unit file. |
| `FN_CLOCK_SKEW` | `60` | Seconds of tolerance on a proof timestamp. |
| `FN_BUNDLE_RATE` / `FN_BUNDLE_RATE_TARGET` | `120` / `6` | Bundle fetches per hour, per requester and per pair. |
| `FN_SEND_RATE` | `600` | Envelopes per hour. |
| `FN_DELIVERED_RETENTION_DAYS` | `1` | |
| `FN_UNDELIVERED_RETENTION_DAYS` | `30` | Must stay below the client's retired-SPK retention (45 days). |
| `FN_ATTACHMENTS_DIR` | `attachments` | Blob directory. Relative paths resolve under `FN_WORKDIR`. |
| `FN_ATTACHMENT_MAX_BYTES` | `10485760` | 10 MiB per blob. |
| `FN_ATTACHMENT_RATE` / `FN_ATTACHMENT_BYTES_RATE` | `60` / `200 MiB` | Per uploader, per hour. |
| `FN_ATTACHMENT_RETENTION_DAYS` | `30` | |
| `FN_TURN_SECRET` / `FN_TURN_HOST` | unset | Unset means `GET /e2e/turn` answers 503 — calling is off on this instance, which is a different thing from not permitted. |
| `FN_TURN_TTL` | `7200` | |
## Auth
Two modes, deliberately.
**Identified routes** take `Authorization: Bearer <token>` *plus* a
proof-of-possession signature. The token is not a bearer credential:
- scoped to **one device** (`dev`), so one stolen from a browser cannot drain
the phone's prekey pool or read its inbox;
- bound to **one relay** (`aud`), so a token minted by one instance is not
valid at another;
- bound to the device's `IK_sig` (`cnf`), and every request carries
`X-FN-Timestamp`, `X-FN-Nonce` and `X-FN-Signature` over
`method \n path \n timestamp \n nonce \n sha256(body)`. A stolen token is
inert without the key. Proofs are single-use within the skew window.
The issuer's verification key is **configuration**, never fetched. Fetched over
HTTPS, a compromised identity provider could serve a key of its own and mint
tokens this relay accepts — which is the exact compromise the split exists to
survive. Rotation is a deploy, on purpose.
**`POST /e2e/messages` is different.** A sealed submission presents the
recipient's delivery key and is otherwise unauthenticated, exactly as the earlier
relay had it. Requiring a token there would hand the relay the sender's identity
at request time, so sealing would hide the sender from the stored row and from
nobody else. Sealed traffic is metered by recipient and by IP, because there is
no sender to meter.
## Routes
All of SPEC.md §8: devices (§8.1), bundles (§8.2), device list (§8.3),
revocation (§8.3a), send and poll (§8.4/§8.5), prekey replenishment (§8.6),
sealed attachments (§8.7/§8.8), the group registry (§8.9), and TURN credentials
(§8.10).
Two deliberate deviations from what the earlier implementation did, both
forced by the same thing — this service is not the identity provider:
- **Group members are ids only**, with no `name`. The earlier server joined a user table for
a display name. A relay that caches display names is a relay that leaks them;
clients resolve names against the portal, as they already do for buddy lists.
- **Group membership requires a reachable device**, rather than
`get_userdata()`. The relay cannot answer "is this a real account". It can
answer "can this member receive a single group envelope", which is stricter:
fan-out is per device, so a member with none is a silent black hole.
## What this does not fix
Stated here because a rewrite implies it fixed everything it touched.
- **Key transparency is still absent.** This service is still the sole
distributor of prekey bundles; the split reduced its power over
key distribution by exactly zero. It is a precondition for federation, not a
follow-on.
- **Group rosters** are not implemented here yet, and remain server-visible
where they are.
- **Profiles** are server-visible.
- **IdP compromise** still buys envelope deletion, prekey drain and traffic
observation. A registration lock stops device registration, not everything.
- **Web client delivery.** A separate origin removes third-party scripts; it
does not change who ships the code. On the web the relay still serves the
client, so "defends against a compromised relay" holds on native and not in
the browser.
## Tests
`node ../test/relay.mjs` boots this binary and drives it over real HTTP against
SPEC.md §8 — including the negative cases, which are the point: a forged proof,
a replayed proof, a token for another relay, a token signed by an unpinned
issuer, and a token used against a device it was not scoped to.
It skips rather than fails when the binary is not built, so the JS suite still
runs without a Swift toolchain. 60 assertions, including a path-traversal probe
against the attachment id and a cross-implementation check that the coturn REST
credential this mints matches one computed independently in Node.
+61
View File
@@ -0,0 +1,61 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>About this project</h1>
<p class="lede">
Flamenet Messenger is a private messenger, built in the open by people who would
rather explain how it works than ask you to take it on faith.
</p>
<h2>What it is</h2>
<p>
There are three parts. The apps you install, which do the locking and unlocking on
your own device. The server, which passes sealed messages along without being able
to read them. And a written description of exactly how the two talk to each other,
detailed enough that somebody else could build a compatible app from it.
</p>
<p>
All of it is free, and all of it is published. The server is small enough that
running your own is an afternoon rather than a project.
</p>
<h2>Why it exists</h2>
<p>
Most private messengers ask you to trust a company you cannot look inside and a
server you cannot run. That is a reasonable thing to ask, and plenty of people are
happy to say yes. This project is built for the case where you would rather not
have to: the design is written down, the code is there to read, and if you would
prefer your messages never touched our machines at all, you can run the whole thing
on your own.
</p>
<h2>What it isn't</h2>
<p>
It isn't finished. The apps are still being built, and some of what the design
describes is working on one platform and not yet on another. Rather than round that
up into a single claim, we track it platform by platform in the
<a href="/spec">specification</a> and on the <a href="/roadmap">roadmap</a>.
</p>
<p>
No outside security firm has reviewed it yet. That costs real money, and it is what
<a href="/donate">donations</a> are for.
</p>
<p>
It also isn't anonymous. The server has to know who to deliver a message to, so it
can see that you wrote to someone and roughly when — it simply cannot read what you
said. There is a <a href="/metadata">page listing exactly what is kept</a>, field by
field.
</p>
<h2>Licence</h2>
<p>
The engine and the server are published under the MIT licence, which means anyone may
read, use or build on this work. The apps are not published yet. See
<a href="/licenses">licences</a> for the full text and for the other open source
components this project relies on.
</p>
</div>
</article>
#endexport
#endextend
+152
View File
@@ -0,0 +1,152 @@
#extend("layout"):
#export("content"):
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="audit-scope">Audit scope</h1>
<p>A brief for an external reviewer. Auditors bill by the hour and spend
the first of them working out what the system is and what is worth
attacking — this document is an attempt to give that away for free
rather than pay for it twice.</p>
<p>Nothing here asks for a conclusion. Where this project believes
something is sound, that belief is stated so it can be attacked
directly.</p>
<h2 id="1-what-the-system-is">1. What the system is</h2>
<p>Three components, one repository:</p>
<table>
<tbody>
<tr>
<td><code>engine/src/</code></td>
<td>JavaScript</td>
<td>Reference cryptographic engine. Normative where implementations
disagree.</td>
</tr>
<tr>
<td><code>Sources/E2E/</code></td>
<td>Swift (CryptoKit)</td>
<td>iOS engine. Checked against the reference in both directions.</td>
</tr>
<tr>
<td><code>server/</code></td>
<td>Swift (Vapor)</td>
<td>The relay. Holds no key that opens an encrypted message.</td>
</tr>
</tbody>
</table>
<p>There is no Android implementation. There is no browser client — a
web client would be re-delivered by the server on every load, which is
the property the design exists to avoid needing to trust.</p>
<h2 id="2-what-is-in-scope">2. What is in scope</h2>
<p>In rough order of what would hurt most if wrong:</p>
<ol type="1">
<li><strong>Session establishment</strong> — X3DH and its post-quantum
hybrid (spec §5, §12). Key confusion, missing domain separation,
associated-data mismatches.</li>
<li><strong>The Double Ratchet</strong> (§6, §13). Nonce reuse,
chain-key reuse across a ratchet step, out-of-order and skipped-message
handling.</li>
<li><strong>Token authentication and proof-of-possession</strong> (§8,
<code>Token.swift</code>). Replay, cross-device use, cross-relay use,
and the single-use proof cache.</li>
<li><strong>Sealed sender</strong> (§14). Whether a sealed submission
leaks its sender through any path — including rate-limit state,
attachment rows, and error timing.</li>
<li><strong>Key transparency</strong> (§19). Whether inclusion and
consistency verification can be made to accept a substituted key.</li>
<li><strong>Post-quantum identity</strong> (§20). Hybrid verification,
and whether the downgrade pin can be evaded.</li>
<li><strong>At-rest encryption</strong>
(<code>AtRestCrypto.swift</code>). Applies only to conversations with
encryption deliberately turned off.</li>
</ol>
<h2 id="3-what-is-explicitly-out-of-scope">3. What is explicitly out of
scope</h2>
<p>Not because it is safe, but because it is known:</p>
<ul>
<li><strong>Metadata.</strong> The relay sees recipient, timing,
approximate size and source address. <code>/metadata</code> documents
what is stored and for how long.</li>
<li><strong>Group rosters.</strong> Server-visible by design; §22
records the alternative.</li>
<li><strong>Compromised endpoints.</strong> Keys live on devices;
malware on one reads plaintext.</li>
<li><strong>Post-quantum authentication below iOS 26.</strong> No
<code>MLDSA65</code>, so those devices are classical-only and say
so.</li>
</ul>
<h2 id="4-trust-boundaries">4. Trust boundaries</h2>
<ul>
<li><strong>Client ⇄ relay.</strong> The relay is assumed hostile. It
distributes prekey bundles, so it is the natural place to attempt key
substitution — §19 exists to make that leave evidence, and §19.4 gossip
is what detects a fork.</li>
<li><strong>Issuer ⇄ relay.</strong> The issuer's verification key is
<strong>configuration, never fetched</strong>. A relay that fetched it
over HTTPS could be handed a key by a compromised issuer.</li>
<li><strong>Device ⇄ device.</strong> Peers authenticate through the
ratchet. Identity changes are pinned and surfaced rather than accepted
silently.</li>
</ul>
<h2 id="5-assets">5. Assets</h2>
<p>Ordered by consequence of disclosure:</p>
<ol type="1">
<li>Device identity private keys (<code>ikSig</code>, <code>ikDH</code>)
— impersonation.</li>
<li>The issuer signing seed — mints tokens the relay accepts for any
device.</li>
<li>Ratchet state — decrypts the messages of the sessions it
covers.</li>
<li>The at-rest key — opens stored non-E2E message bodies.</li>
<li>Delivery keys — permit sending sealed to a recipient; not
confidentiality.</li>
</ol>
<h2 id="6-where-to-start-reading">6. Where to start reading</h2>
<ul>
<li><code>engine/docs/SPEC.md</code> — the protocol, primitives through
REST contract.</li>
<li><code>engine/src/e2e.js</code> — the reference engine.</li>
<li><code>server/Sources/FlamenetRelay/Routes.swift</code> — §8
handlers, including the one deliberately unauthenticated route.</li>
<li><code>server/Sources/FlamenetRelay/Token.swift</code> — token and
proof verification.</li>
<li><code>engine/test/vectors/protocol-v1.json</code> — published
vectors, reproduced by both engines.</li>
</ul>
<h2 id="7-claims-we-would-like-attacked">7. Claims we would like
attacked</h2>
<p>Stated as falsifiable propositions rather than assurances:</p>
<ol type="1">
<li>A relay cannot read the contents of an encrypted conversation.</li>
<li>A relay cannot serve a device key it has not first committed to the
log, without two clients who talk to each other detecting it.</li>
<li>A sealed submission's stored record does not identify its sender,
and no other row does either.</li>
<li>A token stolen from one device does not authorise action as another
device, nor at another relay.</li>
<li>A device that has published a post-quantum identity cannot be
presented as classical to a client that has already seen it.</li>
</ol>
<h2 id="8-known-unsound-do-not-spend-time-confirming">8. Known-unsound,
do not spend time confirming</h2>
<ul>
<li>The at-rest key is held by the operator. Non-E2E conversations are
readable by whoever runs the relay. Any marketing claim to the contrary
is a bug in the copy, and the copy has been corrected.</li>
<li>Sealed sender is not anonymity. The connection is still
observed.</li>
<li>Key transparency without client gossip detects a server that changes
its mind, not one that was never honest. Gossip is implemented on web
and iOS; a fork along social boundaries still evades it.</li>
</ul>
<h2 id="9-test-surface">9. Test surface</h2>
<p><code>engine/</code> — five suites, run with <code>npm test</code>.
The relay suite boots the compiled binary and drives §8 over HTTP,
negative cases included. The interop suite compiles the iOS engine from
the app's own sources, so a drift between engines fails here rather than
on a phone.</p>
<p>A green run is not evidence of security. It is evidence that the
specific things we thought to check still hold.</p>
</div>
</article>
#endexport
#endextend
+46
View File
@@ -0,0 +1,46 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Community</h1>
<p class="lede">
This is an open source project, and quite a lot of the help it needs isn't code.
</p>
<h2>Read the protocol</h2>
<p>
Perhaps the most valuable thing anyone can do is read the
<a href="/spec">specification</a> and tell us where it is wrong, unclear, or leaves
something unsaid. A design document only its author has read is a design that has
only been checked once.
</p>
<h2>Write an implementation</h2>
<p>
The specification is deliberately complete enough to build against, and there are
published test cases to check your work with. If a third implementation disagrees
with ours, that is about the most useful bug report there is.
</p>
<h2>Report a bug</h2>
<p>
Ordinary bugs belong in the issue tracker on
<a href="/source">the repository</a>.
</p>
<h2>Report a vulnerability</h2>
<p>
Anything affecting confidentiality or authentication should not go in a public
issue. See the <a href="/security">security page</a>, or go straight to
<a href="/.well-known/security.txt">security.txt</a>.
</p>
<h2>Run a server</h2>
<p>
Every independently run server makes self-hosting more real, and tends to surface
the assumptions we did not realise we had baked in.
</p>
</div>
</article>
#endexport
#endextend
+147
View File
@@ -0,0 +1,147 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap">
<h1>How this compares</h1>
<p class="lede lede-wide">
Four messengers, side by side. Two things on this table only Flamenet
Messenger does: it runs on a server you control, and it never asks for
your phone number. The rest is laid out plainly and sourced from each
project's own documentation, so you can check every line of it.
</p>
<div class="compare-scroll">
<table class="compare">
<thead>
<tr>
<th scope="col">&nbsp;</th>
<th scope="col" class="mine">Flamenet</th>
<th scope="col">Signal</th>
<th scope="col">Telegram</th>
<th scope="col">Apple Messages</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Encrypted end to end by default</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>An opt-in plain mode also exists, and the app shows which one a chat is in.</small></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="no">No</strong><br><small>Only in Secret Chats, which are one-to-one and have to be started deliberately.</small></td>
<td><strong class="yes">Yes</strong><br><small>Between Apple devices, and with Android over RCS since iOS 26.5.</small></td>
</tr>
<tr>
<th scope="row">Works without a phone number</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>A username and an email address.</small></td>
<td><strong class="no">No</strong><br><small>A number is required to register. Usernames hide it from other people, but not from Signal.</small></td>
<td><strong class="no">No</strong></td>
<td><strong class="part">Partly</strong><br><small>An Apple ID, reachable by phone number or email.</small></td>
</tr>
<tr>
<th scope="row">You can run the server yourself</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>Two containers and a config file.</small></td>
<td><strong class="no">No</strong></td>
<td><strong class="no">No</strong></td>
<td><strong class="no">No</strong></td>
</tr>
<tr>
<th scope="row">Source code published</th>
<td class="mine"><strong class="part">Engine and server</strong><br><small>Under the MIT licence. The apps are not published yet.</small></td>
<td><strong class="yes">Apps and server</strong><br><small>Under the AGPL and GPL.</small></td>
<td><strong class="part">Apps only</strong><br><small>The server side is closed.</small></td>
<td><strong class="no">No</strong></td>
</tr>
<tr>
<th scope="row">Post-quantum key agreement</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>ML-KEM-768, mixed into the handshake.</small></td>
<td><strong class="yes">Yes</strong><br><small>Using PQXDH.</small></td>
<td><strong class="no">No</strong></td>
<td><strong class="yes">Yes</strong><br><small>Using PQ3.</small></td>
</tr>
<tr>
<th scope="row">Post-quantum in the ongoing ratchet</th>
<td class="mine"><strong class="part">Handshake only</strong><br><small>Which is the part that stops traffic captured today being decrypted by a future computer.</small></td>
<td><strong class="no">No</strong></td>
<td><strong class="no">No</strong></td>
<td><strong class="yes">Yes</strong><br><small>The most advanced of the four on this row.</small></td>
</tr>
<tr>
<th scope="row">Keys verified automatically, not just by hand</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>Keys go into a public append-only log, and the app refuses one that isn't in it.</small></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="no">No</strong></td>
<td><strong class="yes">Yes</strong><br><small>Contact Key Verification, opt-in.</small></td>
</tr>
<tr>
<th scope="row">Independent security audit</th>
<td class="mine"><strong class="part">Not yet</strong><br><small>The scope for one is written and public, and donations pay for it. Until then the code is open and heavily tested.</small></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="part">Partly</strong><br><small>Its protocol has been examined externally, with mixed findings.</small></td>
<td><strong class="yes">Yes</strong><br><small>PQ3 was formally analysed by outside academics.</small></td>
</tr>
<tr>
<th scope="row">You can install it today</th>
<td class="mine"><strong class="part">Nearly</strong><br><small>The iPhone app is in build and the Android engine has started.</small></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="yes">Yes</strong></td>
</tr>
<tr>
<th scope="row">People already using it</th>
<td class="mine"><strong class="part">Early days</strong><br><small>It is new, and the people who arrive now are the ones who shape what it becomes.</small></td>
<td><strong class="yes">Many millions</strong></td>
<td><strong class="yes">Hundreds of millions</strong></td>
<td><strong class="yes">Built into every iPhone</strong></td>
</tr>
<tr>
<th scope="row">What pays for it</th>
<td class="mine">Donations</td>
<td>Donations and grants, to a non-profit</td>
<td>Ads and subscriptions</td>
<td>Selling the hardware it runs on</td>
</tr>
</tbody>
</table>
</div>
<h2>Reading the table fairly</h2>
<p>
Rows are easy to count and harder to weigh. Apple's key transparency has
run for years across a billion devices; ours is new, and scale is a
security property of its own. Worth saying plainly, because we would
rather you trust the table than the tone of it.
</p>
<p>
All four are serious pieces of engineering, and Apple is ahead of everyone
here on post-quantum. But every other messenger on this table has one
thing in common: somebody else runs the server, and you are trusting them
to. Flamenet Messenger is built so that you do not have to — the whole
thing runs on a machine you control, under a licence that lets you keep it
that way, with no phone number attached to any of it. That is the trade
this project exists to remove, and nothing else here offers it.
</p>
<h2>Where these facts come from</h2>
<p>
Everything above is drawn from each project's own published documentation
and from public security research, checked in August 2026. These are given
as references rather than links, because this site deliberately doesn't
send you anywhere else:
</p>
<ul>
<li><strong>Signal</strong> — Signal Support, "Phone Number Privacy and Usernames"; Signal blog, "Keep your phone number private with Signal usernames"; the PQXDH specification.</li>
<li><strong>Telegram</strong> — Telegram Core API, "End-to-End Encryption, Secret Chats", which documents that cloud chats are client–server encrypted and that Secret Chats are opt-in and one-to-one.</li>
<li><strong>Apple</strong> — Apple Security Research, "iMessage with PQ3"; Apple Support, "iCloud data security overview"; the iOS 26.5 release notes covering encrypted RCS; Stebila, "Security analysis of the iMessage PQ3 protocol", and the USENIX Security 2025 formal analysis of PQ3.</li>
<li><strong>Flamenet Messenger</strong> — the <a href="/spec">specification</a>, the <a href="/metadata">metadata page</a> and the <a href="/roadmap">roadmap</a> on this site.</li>
</ul>
<p class="meta">
If you find a row here that is out of date or unfair to one of the others,
that is a bug and we would like to hear about it — see
<a href="/community">community</a>. Signal is a trademark of Signal
Messenger, LLC; Telegram is a trademark of Telegram Messenger Inc.; Apple
and iMessage are trademarks of Apple Inc. This project is not affiliated
with, endorsed by, or connected to any of them.
</p>
</div>
</article>
#endexport
#endextend
+84
View File
@@ -0,0 +1,84 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Donate</h1>
<p class="lede">
This project has no company behind it, no investors, and nothing to upsell you. If
it is useful to you and you can spare something, it goes straight into the parts
that cost money.
</p>
<h2>What your money actually pays for</h2>
<ul>
<li><strong>Running the server.</strong> A server, its bandwidth, and the domain.</li>
<li><strong>An external security audit.</strong> The largest single line, and the
one thing on the roadmap that cannot be solved by writing more code. A real audit
of a protocol this size is thousands, not hundreds.</li>
<li><strong>Apple's developer programme</strong>, without which the iOS client
cannot be distributed at all.</li>
</ul>
<h2>What it does not buy</h2>
<p>
Not features, not priority support, and not a say in how the protocol works. There
is no donor tier and nothing is locked behind paying — someone who reads the design
carefully has just as much influence as someone who sends money.
</p>
<h2>Other ways to help, which are worth more</h2>
<p>
Honestly, money is not the most useful thing most people can offer. Reading the
<a href="/spec">specification</a> and telling us where it is wrong is worth more.
So is <a href="/self-hosting">running your own server</a>, or writing an
implementation against the published vectors. See <a href="/community">community</a>.
</p>
<h2>Give</h2>
#if(donateEnabled):
<form class="donate-form" method="POST" action="/donate/checkout">
<fieldset class="donate-freq">
<legend>How often</legend>
<label><input type="radio" name="frequency" value="once" checked> One time</label>
<label><input type="radio" name="frequency" value="monthly"> Monthly</label>
</fieldset>
<fieldset class="donate-amounts">
<legend>Amount</legend>
<label><input type="radio" name="amount" value="5"> $5</label>
<label><input type="radio" name="amount" value="15" checked> $15</label>
<label><input type="radio" name="amount" value="50"> $50</label>
<label><input type="radio" name="amount" value="150"> $150</label>
<label class="donate-custom">
<input type="radio" name="amount" value="custom">
<span>Other</span>
<input type="number" name="custom_amount" min="1" max="10000" step="1"
inputmode="numeric" placeholder="$" aria-label="Custom amount in dollars">
</label>
</fieldset>
<button class="btn btn-primary" type="submit">Continue to payment</button>
<p class="fineprint fineprint-flush">
This form collects an amount and nothing else. Payment happens on
#(processorName)'s own hosted page — card details never reach this site, and this
site never sees them. No account is created here and no newsletter exists to be
added to.
</p>
</form>
#else:
<p class="donate-pending">
No payment processor is configured on this server, so there is nothing here to click.
This is the honest state rather than a button that fails after you have decided to
give.
</p>
<p class="fineprint">
An operator enables it by setting <code>FN_DONATE_PROVIDER</code> and the matching
key; the form above then appears and nothing else on this page changes.
</p>
#endif
</div>
</article>
#endexport
#endextend
+178
View File
@@ -0,0 +1,178 @@
#extend("layout"):
#export("content"):
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-e2e">flamenet-e2e</h1>
<p>The end-to-end encryption engine behind <a
href="/">FlameNet Messenger</a> — X3DH +
Double Ratchet with an optional hybrid post-quantum key agreement, in
dependency-free JavaScript that runs in a browser or in Node.</p>
<p>This is the <strong>reference implementation</strong> of <a
href="docs/SPEC.md"><code>docs/SPEC.md</code></a>. The iOS (CryptoKit)
engine implements the same document and is checked against this one in
both directions by <code>test/interop.mjs</code>; where they disagree,
this engine is the tiebreaker. An Android engine does not exist yet.</p>
<p>⚠️ <strong>iOS post-quantum requires iOS 26+</strong>, because that
is when CryptoKit gained <code>MLKEM768</code>. Older devices speak v1.
See §12.11 of the spec.</p>
<h2 id="what-it-is">What it is</h2>
<ul>
<li><strong>X3DH</strong> asynchronous key agreement, <strong>Double
Ratchet</strong> for per-message forward secrecy and post-compromise
security.</li>
<li><strong>v2 adds hybrid post-quantum key agreement</strong> —
ML-KEM-768 (FIPS 203) mixed into the X3DH secret alongside the classical
X25519 DHs. Hybrid means <code>SK</code> holds if <em>either</em>
primitive holds; an ML-KEM failure degrades to v1's classical security
rather than to nothing.</li>
<li><strong>Per-device identities</strong>, safety numbers, identity
pinning, and signed capability advertisement with per-device downgrade
pinning. A device that has proved it speaks v2 can never be negotiated
back down to v1 by anything the relay says.</li>
<li><strong>No dependencies at runtime.</strong> X25519, Ed25519,
HKDF-SHA256 and AES-256-GCM come from WebCrypto. ML-KEM-768 uses the
platform's when it has one and a single vendored bundle otherwise.</li>
<li><strong>Post-quantum ratchet</strong> (v3) — every ratchet step
mixes a fresh ML-KEM secret, so post-compromise recovery is post-quantum
too, not just session establishment.</li>
<li><strong>Sealed sender</strong> — the relay stores no sender identity
for a sealed message.</li>
<li><strong>Encrypted backups</strong> and <strong>multi-device
sent-copies</strong>, both client-side only: there is no server route
for either, by design.</li>
<li><strong>The server is here too</strong>, in <code>server/</code>.
Self-hosting is not a claim you have to take on trust — the relay is the
same code, published.</li>
</ul>
<h2 id="what-it-is-not">What it is not</h2>
<p><strong>This code has not been independently audited.</strong>
Neither has the vendored ML-KEM implementation it can fall back to (see
<a href="vendor/PROVENANCE.md"><code>vendor/PROVENANCE.md</code></a>).
It was reviewed internally in August 2026 and has a conformance suite,
which is not the same thing as an audit. Read <a
href="SECURITY.md"><code>SECURITY.md</code></a> before deploying it for
anyone whose safety depends on it.</p>
<p>Specifically, it does <strong>not</strong> provide: post-quantum
<em>authentication</em> (identities are still Ed25519), sealed sender,
encrypted backups, multi-device history sync, or reproducible builds. A
relay running this protocol still sees who messages whom and when.</p>
<h2 id="layout">Layout</h2>
<pre><code>server/ the relay: a standalone Swift service (see server/README.md)
src/e2e.js the engine — X3DH, PQX3DH, Double Ratchet, sessions, safety numbers
src/mlkem.js ML-KEM-768: native WebCrypto first, vendored fallback second
src/e2e-client.js REST client + manager — pinning, trust, registration, replenishment
src/e2e-bootstrap.js page wiring
src/e2e-vault.js encrypted IndexedDB store for keys and ratchet state
vendor/ bundled @noble/post-quantum, MIT, rebuildable via scripts/build-vendor.sh
test/ five suites; run them before trusting anything here
docs/SPEC.md the protocol contract — §12 is the post-quantum delta</code></pre>
<h2 id="use">Use</h2>
<pre class="js"><code>import { DeviceIdentity, E2ESession } from &#("#")39;./src/e2e.js&#("#")39;;
// One-time, per device.
const me = await DeviceIdentity.create();
await me.generateOneTimePreKeys(100);
await me.generatePQOneTimePreKeys(100); // keep both pools the same size
// Publish: ikDH.pub, ikSig.pub, signedPreKey.pub + signedPreKeySignature(),
// pqSignedPreKey.publicKey + pqSignedPreKeySignature(), capabilitySignature(),
// and the two prekey arrays.
// Start a session from a peer&#("#")39;s bundle. `minProto` is what you pinned for that
// device: pass 2 once you have seen it advertise v2, and a stripped bundle then
// throws `protocolDowngrade` instead of quietly establishing v1.
const session = await E2ESession.initiate(me, peerBundle, minProto);
const envelope = await session.encrypt(&#("#")39;hello&#("#")39;);
// The other side:
const theirs = await E2ESession.respond(me, envelope);
await theirs.decrypt(envelope);</code></pre>
<p>Sessions and identities serialize with <code>archive()</code> /
<code>fromArchive()</code>. Both hold secret key material — persist them
in an encrypted store, never in <code>localStorage</code>.</p>
<h2 id="tests">Tests</h2>
<pre class="bash"><code>npm test</code></pre>
<p>Two suites.</p>
<p><strong><code>test/conformance.mjs</code></strong> — the engine
alone: backend agreement, v1 regression, PQX3DH establishment, prekey
exhaustion, tamper rejection, downgrade refusal, prekey consumption,
reload persistence, and a locked known-answer vector. Node ≥ 20 for
WebCrypto X25519/Ed25519; Node ≥ 25 additionally exercises native ML-KEM
and cross-checks it against the vendored implementation.</p>
<p><strong><code>test/client.mjs</code></strong> —
<code>E2EClient</code> / <code>E2EManager</code> against a deliberately
<strong>hostile</strong> in-process relay: one that strips PQ fields
from bundles and forges capability signatures. Covers protocol-version
pinning and its monotonicity, downgrade refusal, upgrade of a device
that registered before v2, prekey replenishment from the server's
counts, and the rule that accepting an identity change must not reset a
protocol pin. The real server will not attack you, so this is the only
place that behaviour can be tested.</p>
<p><strong><code>test/relay.mjs</code></strong> — the engine against the
<strong>real relay</strong>, over HTTP. It boots the compiled binary and
drives §8 against it: registration, bundle fetch, send and poll, the
sealed-sender delivery-key capability, device revocation, and TURN. The
negative cases are the point — a forged proof, a replayed proof, a token
minted for another relay, a token used against a device it was not
scoped to, a wrong delivery key, and path traversal on an attachment id.
This is the test that catches a client and a server disagreeing about
the wire format, which is the one bug this protocol has actually
shipped. Skips cleanly when the binary has not been built.</p>
<p><strong><code>test/interop.mjs</code></strong> — the <strong>iOS
engine against this one</strong>, both directions. The Swift driver
(<code>test/interop/</code>) is compiled by
<code>interop/build.sh</code> from the iOS app's own
<code>Sources/E2E/*.swift</code>, so it is the real engine rather than a
reimplementation: if the two drift apart, this fails instead of a user's
phone. Covers both handshake directions, identical associated data, the
last-resort prekey path, tamper rejection and safety-number agreement.
Skips cleanly off macOS or without the app checkout
(<code>FNMSG_APP_DIR</code> overrides the default
<code>~/Code/flamenetmessenger</code>).</p>
<p>Server-side behaviour is covered by the relay's own Swift test target
alongside <code>test/relay.mjs</code>.</p>
<h2 id="protocol-versions">Protocol versions</h2>
<table>
<thead>
<tr>
<th></th>
<th>v1</th>
<th>v2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Key agreement</td>
<td>X3DH (X25519)</td>
<td>X3DH + ML-KEM-768, hybrid</td>
</tr>
<tr>
<td>KDF info</td>
<td><code>FlamenetE2E_X3DH_v1</code></td>
<td><code>FlamenetE2E_X3DH_v2</code></td>
</tr>
<tr>
<td>Associated data</td>
<td>64 B</td>
<td>96 B (adds a binding over the KEM ciphertext)</td>
</tr>
<tr>
<td>Ratchet, AEAD, safety numbers</td>
<td>—</td>
<td>unchanged</td>
</tr>
</tbody>
</table>
<p>A v2 client speaks v1 to peers that do not advertise v2, so the two
interoperate during rollout. <code>Policy.pqMode = 'required'</code>
disables that fallback once every client has migrated; until then,
downgrade resistance rests on per-device pinning, and no post-quantum
claim should be made to users. See §12.7.</p>
<h2 id="licence">Licence</h2>
<p>MIT — see <a href="LICENSE"><code>LICENSE</code></a>. Vendored ML-KEM
is MIT (Paul Miller); its licences are in <code>vendor/</code>.</p>
</div>
</article>
#endexport
#endextend
+435
View File
@@ -0,0 +1,435 @@
#extend("layout"):
#export("content"):
<header class="hero">
<div class="wrap hero-inner">
<div class="hero-copy">
<span class="eyebrow">Private messaging</span>
<h1>Simple, private <span class="hl">messaging</span>.</h1>
<p class="lede">
Messages are locked on your phone and can only be opened by the person you
sent them to. No phone number, no ads, and nobody reading over your shoulder.
We're also upfront about where the limits are.
</p>
<div class="cta-row">
<a class="btn btn-primary" href="#("#")get">Get the app</a>
<a class="btn btn-ghost" href="#("#")how">See how it works</a>
</div>
<div class="hero-chips">
<span class="chip"><span class="chip-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>No phone number</span>
<span class="chip"><span class="chip-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>No ads, no trackers</span>
<span class="chip"><span class="chip-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>Free and open source</span>
</div>
</div>
<div class="hero-art" aria-hidden="true">
<div class="phone">
<div class="phone-screen">
<div class="bubble in">still on for saturday?</div>
<div class="bubble out">wouldn't miss it</div>
<div class="bubble in">bringing the dog 🐕</div>
<div class="bubble out">now I'm definitely coming</div>
</div>
</div>
</div>
</div>
</header>
<section class="proof-band">
<div class="wrap">
<p class="proof-lead">Free to use, open to read, and <strong>yours to run on your own server</strong></p>
<div class="proof">
<span><strong>Free</strong> forever</span>
<span><strong>No</strong> ads</span>
<span><strong>No</strong> phone number</span>
<span><strong>Open</strong> source</span>
<span><strong>Yours</strong> to host</span>
</div>
</div>
</section>
<section class="band band-center" id="features">
<div class="wrap">
<span class="eyebrow">Features</span>
<h2>What the app <span class="hl">does for you</span>.</h2>
<p class="band-lede">
In everyday terms, without the jargon. A little further down, the parts that
deserve a longer explanation.
</p>
<div class="cards">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11V8a7 7 0 0 1 14 0v3"/><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M12 15v2"/></svg></span>
<h3>Only the two of you can read it</h3>
<p>
Your message is locked before it leaves your phone, and only your friend's
phone has what it takes to open it. We just pass the sealed envelope along.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20v-1a6 6 0 0 1 6-6h3a6 6 0 0 1 6 6v1"/><circle cx="11.5" cy="7" r="4"/></svg></span>
<h3>No phone number</h3>
<p>
You sign up with a username and an email, and people find you by the name
they already call you. Your phone number stays out of it.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg></span>
<h3>No ads, and nothing to sell</h3>
<p>
There are no ads, no trackers, and nothing watching which chats you open.
With no advertiser in the picture, there's no reason to build a profile of
you in the first place.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/><path d="M3.5 9h4"/></svg></span>
<h3>Messages that clean up after themselves</h3>
<p>
You can set a chat to delete itself after an hour, a day, or anything up to a
month — handy if you'd rather old conversations didn't sit on a phone
indefinitely.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20v-1a6 6 0 0 1 6-6h1"/><circle cx="10.5" cy="7" r="3.5"/><path d="M17 14v4"/><path d="M17 21h.01"/></svg></span>
<h3>Strangers ask first</h3>
<p>
A first message from someone you've never spoken to arrives as a request you
can accept or ignore. And if you block someone, the server itself stops
accepting their messages, rather than the app quietly hiding them.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="10" height="16" rx="2"/><path d="M9 15h.01"/><rect x="14" y="10" width="7" height="12" rx="2"/><path d="M17.5 19h.01"/></svg></span>
<h3>If you lose a phone</h3>
<p>
Every device you sign in on has its own set of keys. Sign one out from another
device and its messages and keys are removed along with it.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 4v5c0 4-3 7-7 9-4-2-7-5-7-9V7z"/><path d="M9 12l2 2 4-4"/></svg></span>
<h3>Keys you can check up on</h3>
<p>
Every key we hand out is written into a public list that can't be edited
afterwards, and your app checks that list before it trusts a key. Apps also
compare notes with one another, so if anyone were ever shown a different
answer, it would come to light.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01"/><path d="M7 17h.01"/></svg></span>
<h3>You can host it yourself</h3>
<p>
The whole server is a single command to start on a machine you control, and
the apps can point at yours instead of ours.
</p>
</article>
</div>
</div>
</section>
<section class="band band-alt" id="honest">
<div class="wrap">
<div class="band-head">
<span class="eyebrow">Good to know</span>
<h2>A few things <span class="hl">worth knowing</span>.</h2>
<p class="band-lede">
Every messenger has limits. These are ours, in plain terms, so that nothing
about how this works comes as a surprise later on.
</p>
</div>
<div class="honest">
<article class="honest-card">
<h3>Encryption covers the message, not the fact you sent one</h3>
<p>
Our server has to know who to hand a message to, so it can see that you wrote
to someone and roughly when. It cannot read a word of what you said. That is
true of any messenger that delivers to people who are offline.
</p>
<a class="card-link" href="/metadata">See exactly what's kept</a>
</article>
<article class="honest-card">
<h3>Plain mode is a trade-off worth knowing about</h3>
<p>
A chat can be set to store on the server instead, which is what makes search
and previews work. Those messages can be read by whoever runs the server. The
app always shows which mode a chat is in, so the choice stays with you.
</p>
<a class="card-link" href="/privacy">How that's handled</a>
</article>
<article class="honest-card">
<h3>An outside audit hasn't happened yet</h3>
<p>
The code is open and heavily tested, and the design is published so anyone can
review it. A paid review by a security firm is a different kind of assurance,
and it is one we would like to fund.
</p>
<a class="card-link" href="/audit">What an audit would cover</a>
</article>
</div>
</div>
</section>
<section class="band band-center" id="how">
<div class="wrap">
<span class="eyebrow">How it works</span>
<h2>How your messages <span class="hl">stay private</span>.</h2>
<p class="band-lede">
You don't need any of this to use the app. But if you have ever wondered what
"end-to-end encrypted" actually means, it comes down to three ideas.
</p>
<ol class="steps">
<li class="step">
<span class="step-n">1</span>
<h3>Your phone makes a lock and a key</h3>
<p>
It hands out copies of the lock to anyone who wants to write to you. The key
that opens them stays on your phone, and isn't shared with us or anyone
else.
</p>
</li>
<li class="step">
<span class="step-n">2</span>
<h3>Your friend locks the message before sending</h3>
<p>
By the time it reaches our server it's already shut, and we don't have the key.
Neither of you has to be online at the same time for this to work.
</p>
</li>
<li class="step">
<span class="step-n">3</span>
<h3>Every message gets a fresh lock</h3>
<p>
The locks change as the conversation goes along, so even if one were ever
opened, it wouldn't help with the messages that came before it.
</p>
</li>
</ol>
<p class="fineprint">
All three ideas have proper technical names, and if you would like the full
version with the details filled in, it is all in
<a href="/spec">the specification</a>.
</p>
</div>
</section>
<section class="band band-center band-alt" id="get">
<div class="wrap">
<span class="eyebrow">Get it</span>
<h2>Get <span class="hl">Flamenet Messenger</span></h2>
<p class="band-lede">The apps are still being built. Here is where each one stands today.</p>
<div class="platforms">
<!-- TODO: swap the href for the TestFlight invite link once the beta opens. -->
<span class="platform platform-soon">
<span class="platform-name">iPhone</span>
<span class="platform-state">Being built</span>
</span>
<span class="platform platform-soon">
<span class="platform-name">Android</span>
<span class="platform-state">Not started yet</span>
</span>
<a class="platform" href="/self-hosting">
<span class="platform-name">Server</span>
<span class="platform-state">Ready — run your own</span>
</a>
<a class="platform" href="/source">
<span class="platform-name">Source code</span>
<span class="platform-state">Read the code</span>
</a>
</div>
<p class="fineprint">
There is no version that runs in a web browser, and that is a deliberate choice
rather than something still on the list. A web page is delivered afresh by the
server every time you open it, so how private it is depends on trusting whoever
served it that day. An app you have installed doesn't work that way, which is why
we went in that direction.
</p>
</div>
</section>
<section class="band" id="selfhost">
<div class="wrap">
<div class="split">
<div>
<h2>Run it on <span class="hl">your own machine</span>.</h2>
<p class="band-lede">
The server is two containers and a config file. There is nothing to sign up
for and nothing that phones home, and it serves this very page — so a fresh
install explains itself rather than greeting you with an error.
</p>
<p class="fineprint fineprint-flush">
The server is where your account and any undelivered messages live. Running it
yourself keeps all of that on hardware you control.
</p>
</div>
<pre class="code" aria-label="Commands to run your own server"><code>cp .env.example .env
# fill in the three secrets it asks for
docker compose up -d</code></pre>
</div>
</div>
</section>
<section class="band band-center band-alt" id="developers">
<div class="wrap">
<span class="eyebrow">Developers</span>
<h2>Everything is <span class="hl">written down</span>.</h2>
<p class="band-lede">
This part is for anyone who would like the details. Everything the apps do is
written down, and the two engines are tested against each other.
</p>
<div class="cards cards-3">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h10l4 4v12H5z"/><path d="M15 4v4h4"/><path d="M9 13h6"/><path d="M9 16h4"/></svg></span>
<h3>One document, start to finish</h3>
<p>
How the encryption works and how the server behaves are described in one
document, so anyone who wants to write their own version has what they need.
</p>
<a class="card-link" href="/spec">Read the specification</a>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6L4 12l5 6"/><path d="M15 6l5 6-5 6"/><path d="M13 4l-2 16"/></svg></span>
<h3>Two versions, checked against each other</h3>
<p>
The JavaScript and Swift engines are tested against each other and against
published examples, so compatibility is something we measure rather than
assume.
</p>
<a class="card-link" href="/spec#("#")10-cross-engine-interop-status">How they are tested</a>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11V8a5 5 0 0 1 9.5-2"/><rect x="4" y="11" width="14" height="9" rx="2"/></svg></span>
<h3>Open source, free to reuse</h3>
<p>
The encryption engine is public and freely licensed. Read it, reuse it, or
let us know if you spot a mistake.
</p>
<a class="card-link" href="/source">Source code</a>
</article>
</div>
<p class="stack-note">
Nothing here is home-made. The encryption is built from published, standard
building blocks that other secure messengers use too, and the specification
names every one of them and explains why it was chosen.
</p>
<p class="fineprint">
Running protocol version #(protoVersion) · this server answers for <code>#(audience)</code>
</p>
</div>
</section>
<section class="band band-center" id="community-cards">
<div class="wrap">
<span class="eyebrow">Community</span>
<h2>Ways to <span class="hl">look closer</span></h2>
<p class="band-lede">Some of the most useful help we get isn't code — it's someone reading the design closely and telling us what they find.</p>
<div class="commun">
<a class="commun-card" href="/spec">
<span class="commun-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h10l4 4v12H5z"/><path d="M15 4v4h4"/><path d="M9 13h6"/></svg></span>
<span class="commun-inf"><strong>Specification</strong><small>How all of it works, in full</small></span>
</a>
<a class="commun-card" href="/security">
<span class="commun-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 4v5c0 4-3 7-7 9-4-2-7-5-7-9V7z"/></svg></span>
<span class="commun-inf"><strong>Security</strong><small>What we protect against, and how to report a problem</small></span>
</a>
<a class="commun-card" href="/source">
<span class="commun-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6L4 12l5 6"/><path d="M15 6l5 6-5 6"/></svg></span>
<span class="commun-inf"><strong>Source</strong><small>Engine and server, free to copy</small></span>
</a>
</div>
</div>
</section>
<section class="band band-alt faq" id="faq">
<div class="wrap wrap-narrow">
<div class="band-center">
<span class="eyebrow">FAQ</span>
<h2>Common <span class="hl">questions</span>.</h2>
</div>
<details>
<summary>Can you read my messages?</summary>
<p>
For a normal encrypted chat, no — the server holds a sealed envelope and nothing
that opens it. If you switch a chat to plain mode, which is what makes search and
previews work, then whoever runs the server can read those messages. The app
shows you which mode you are in, and if you run the server yourself, that is
you.
</p>
</details>
<details>
<summary>So what does the server know about me?</summary>
<p>
That you have an account, who you write to, when, roughly how big the message
was, and the internet address you connected from. It has to know where to send
things. It doesn't know what any of it said. There's a
<a href="/metadata">page listing every last field</a> rather than a paragraph
summarising it.
</p>
</details>
<details>
<summary>How do I know I'm really talking to my friend?</summary>
<p>
There are two ways to check, and neither one depends on trusting us. Every key
is published to a list that can't be rewritten afterwards, and your app won't
accept a key that isn't in it, so any substitution would leave a permanent
record. You can also compare a short safety number with your friend in person or
over the phone — if the numbers match, there is nobody in between.
</p>
</details>
<details>
<summary>Do I need a phone number?</summary>
<p>
No. A username and an email address. People reach you by the name your friends
already know you by, and your number never enters the picture.
</p>
</details>
<details>
<summary>What does it cost?</summary>
<p>
Nothing, and there is no paid tier waiting in the wings. There are no ads and
nothing to sell either, so <a href="/donate">donations</a> are what keep it
going.
</p>
</details>
<details>
<summary>Can I really run it myself?</summary>
<p>
Yes, and we test it that way. It's a Docker Compose file with the server and its
database; the apps can be pointed at your address instead of ours. The
<a href="/self-hosting">instructions</a> fit on one page.
</p>
</details>
<details>
<summary>Has it been audited?</summary>
<p>
Not by an outside firm yet — that costs money we are still raising. The engine
has a large test suite, the two implementations are checked against each other,
and the design is published so anyone can review it. Those are all worth
something, and they are a different thing from a professional review, so we
keep the two clearly apart.
</p>
</details>
<details>
<summary>What if I lose my phone?</summary>
<p>
Sign that device out from another one you still have. Its keys and anything
still waiting for it are removed together, so a phone that is no longer in your
hands can't receive anything new.
</p>
</details>
</div>
</section>
#endexport
#endextend
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#(title)</title>
<meta name="description" content="#(description)">
<meta property="og:title" content="#(title)">
<meta property="og:description" content="#(description)">
<meta property="og:type" content="website">
<link rel="stylesheet" href="/landing.css?v=#(assetVersion)">
</head>
<body>
<a class="skip" href="#("#")main">Skip to content</a>
<div class="topbar">
<div class="wrap">
<span class="topbar-tag">Open</span>
<span>Free and open source —</span>
<a href="/source">read the code</a>
</div>
</div>
<nav class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/">
<span class="mark" aria-hidden="true"></span>
<span>Flamenet&nbsp;Messenger</span>
</a>
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-links" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div class="nav-links" id="nav-links">
<a href="/#("#")features">Features</a>
<a href="/#("#")how">How it works</a>
<a href="/comparison">Compare</a>
<a href="/#("#")developers">Developers</a>
<a href="/about">About</a>
</div>
<div class="nav-cta">
<a class="btn btn-outline nav-donate" href="/donate">Donate</a>
<a class="btn btn-nav" href="/#("#")get">Get&nbsp;it</a>
</div>
</div>
</nav>
<main id="main">
#import("content")
</main>
<footer class="footer">
<div class="wrap">
<div class="footer-cta">
<h2>Run your own <span class="hl">server</span>.</h2>
<p>Two containers and a config file. Nothing to sign up for, nothing that phones home.</p>
<div class="cta-row footer-cta-row">
<a class="btn btn-primary" href="/self-hosting">Run your own server</a>
<a class="btn btn-ghost" href="/spec">Read the details</a>
</div>
</div>
<div class="footer-cols">
<div class="footer-brand">
<span class="mark" aria-hidden="true"></span>
<span>Flamenet&nbsp;Messenger</span>
</div>
<nav class="footer-col" aria-label="Product">
<h4>Product</h4>
<a href="/#("#")get">Get it</a>
<a href="/#("#")features">Features</a>
<a href="/#("#")how">How it works</a>
<a href="/comparison">How this compares</a>
<a href="/#("#")selfhost">Self-host</a>
</nav>
<nav class="footer-col" aria-label="Developers">
<h4>Developers</h4>
<a href="/source">Source</a>
<a href="/spec">Specification</a>
<a href="/security">Security</a>
<a href="/audit">Audit scope</a>
<a href="/.well-known/security.txt">security.txt</a>
</nav>
<nav class="footer-col" aria-label="Project">
<h4>Project</h4>
<a href="/about">About</a>
<a href="/community">Community</a>
<a href="/roadmap">Roadmap</a>
<a href="/donate">Donate</a>
</nav>
<nav class="footer-col" aria-label="Legal">
<h4>Legal</h4>
<a href="/terms">Terms of Use</a>
<a href="/privacy">Privacy Policy</a>
<a href="/metadata">What we keep</a>
<a href="/licenses">Licenses</a>
</nav>
</div>
<p class="footer-legal">
Flamenet Messenger is free software, published under the MIT licence. Signal is a
trademark of Signal Messenger, LLC; this project is not affiliated with or endorsed
by them. Proudly hosted at
<a class="host-link" href="https://hitthosting.com">Hitt Hosting</a>
<span class="heart" aria-hidden="true">&#9829;</span>
</p>
</div>
</footer>
<script src="/landing.js?v=#(assetVersion)"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Licences</h1>
<p class="lede">Flamenet Messenger's engine and relay are published under the MIT licence.</p>
<h2>MIT Licence</h2>
<pre class="code code-light"><code>Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.</code></pre>
<h2>Third-party components</h2>
<p>The relay is built on the Vapor stack and Apple's open source Swift libraries:</p>
<ul>
<li><strong>Vapor</strong>, <strong>Fluent</strong>, <strong>Leaf</strong> — MIT</li>
<li><strong>SwiftNIO</strong> — Apache 2.0</li>
<li><strong>swift-crypto</strong> — Apache 2.0</li>
<li><strong>SQLite</strong> — public domain</li>
</ul>
<p>
The vendored ML-KEM implementation carries its own provenance record in the
repository, including the digest a build is expected to reproduce.
</p>
<h2>Trademarks</h2>
<p>
Signal is a trademark of Signal Messenger, LLC. This project is not affiliated with,
sponsored by, or endorsed by them; where this site refers to Signal it is describing
published design decisions for comparison.
</p>
</div>
</article>
#endexport
#endextend
+86
View File
@@ -0,0 +1,86 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>What the server keeps</h1>
<p class="lede">
Encryption protects what you said. It does not hide the fact that a conversation
happened, and we would rather show you the difference than describe it. This page
lists what a server running this software actually stores, so you can check the
answer instead of taking our word for it.
</p>
<h2>The short version</h2>
<ul>
<li>The server keeps <strong>who a message is for and when it arrived</strong>, because it cannot deliver it otherwise.</li>
<li>It keeps <strong>the message itself, still locked</strong>, until your app collects it — and it holds nothing that would open it.</li>
<li>It keeps <strong>your account details</strong>: username, email, password (stored scrambled), and the public half of your keys.</li>
<li>It does <strong>not</strong> keep a record of when you added a friend, blocked someone, or joined a group. Those were removed on purpose.</li>
<li>Its own logs contain <strong>no account, device or address</strong> unless an operator deliberately turns on detailed logging.</li>
</ul>
<p>The rest of this page is the same answer with every field spelled out.</p>
<h2>Held while a message is in flight</h2>
<table>
<thead><tr><th>Kept</th><th>Why it has to be</th><th>Gone after</th></tr></thead>
<tbody>
<tr><td>Recipient account and device</td><td>An envelope cannot be delivered without an address</td><td>1 day once delivered</td></tr>
<tr><td>Arrival time and approximate size</td><td>Ordering, and the size is the ciphertext itself</td><td>1 day once delivered</td></tr>
<tr><td>Sender account and device</td><td>Identified sends only — a <strong>sealed</strong> send stores neither</td><td>1 day once delivered</td></tr>
<tr><td>The ciphertext</td><td>It is the message; the server holds no key that opens it</td><td>30 days if never collected</td></tr>
</tbody>
</table>
<h2>Held for as long as the account exists</h2>
<table>
<thead><tr><th>Kept</th><th>Why</th></tr></thead>
<tbody>
<tr><td>Username, email, password hash, display name</td><td>It is an account</td></tr>
<tr><td>Public keys per device</td><td>They are published on purpose — that is what a prekey bundle is</td></tr>
<tr><td>Buddy list, blocks, group rosters</td><td>The server enforces them, so it has to know them</td></tr>
<tr><td>Presence</td><td>Coarsened to the online window before it is written, never to the second</td></tr>
<tr><td>Delivery-key digest</td><td>The hash only. The key itself never reaches the server except inside a sealed submission</td></tr>
</tbody>
</table>
<h2>Deliberately not kept</h2>
<p>
These were removed rather than retained. Each was written and never read, which made
them a durable record of how a social graph formed in exchange for nothing:
</p>
<ul>
<li><strong>Who uploaded an attachment.</strong> The load-bearing one: a sealed envelope stores no sender, and then the attachment it pointed at named the uploader for the life of the blob.</li>
<li><strong>When a buddy was added.</strong></li>
<li><strong>When a block was set.</strong></li>
<li><strong>When someone joined a group.</strong></li>
</ul>
<h2>Logs</h2>
<p>
The server logs <strong>errors and criticals only</strong>. On a healthy day it
writes nothing at all, which is the quietest setting that still tells an operator
when something is wrong. Everything below that has to be switched on deliberately:
<code>info</code> narrates start-up and retention sweeps, and <code>debug</code>
records request URLs and every database query — a protocol URL contains a device id,
which would turn the log file into a durable record of who fetched whose keys, in a
place nothing sweeps.
</p>
<p>
Whatever sits in front of the server keeps its own access log. That is the operator's
to configure, and on a server you run, the operator is you.
</p>
<h2>What none of this changes</h2>
<p>
A server necessarily observes the connection that delivered a message, including its
address. Sealed sender removes the sender from the stored row, not from the network.
Anyone claiming a hosted messenger is metadata-free is describing something else.
</p>
<p>
See also the <a href="/privacy">privacy policy</a> for the retention windows in full,
and <a href="/self-hosting">self-hosting</a> for how to change them.
</p>
</div>
</article>
#endexport
#endextend
+95
View File
@@ -0,0 +1,95 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Privacy Policy</h1>
<p class="meta">Applies to the server operated at this domain. If you run your own server, none of this describes it — there, the operator is you.</p>
<h2>The short version</h2>
<p>
An encrypted conversation is stored as a sealed envelope the server cannot open. It
is deleted once delivered. A conversation with encryption turned off is stored in a
readable form so search and previews work, and the operator can read it. There is
no advertising, no tracking, and no analytics on this website.
</p>
<h2>What the server necessarily sees</h2>
<p>
Encryption protects contents, not the fact of a conversation. To deliver a message
at all, the server handles:
</p>
<ul>
<li>the recipient account and device the envelope is addressed to</li>
<li>the time it arrived and its approximate size</li>
<li>the IP address the request came from</li>
</ul>
<p>
For a sealed message the stored record does not identify the sender, and neither does
any attachment it carries — the blob is stored without an uploader, because recording
one would have handed back exactly what sealing removes. The server still observes the
connection that delivered it, so sealed sender is not anonymity.
</p>
<h2>What is deliberately not recorded</h2>
<p>
Timing that served no request has been removed rather than retained: when you added a
buddy, when you blocked someone, when a member joined a group, and who uploaded an
attachment. None of it was read by anything; all of it would have been a durable
record of how your social graph formed.
</p>
<h2>What is stored, and for how long</h2>
<p>These are the windows the software enforces by default:</p>
<ul>
<li><strong>Delivered encrypted envelopes</strong> — deleted after 1 day.</li>
<li><strong>Undelivered encrypted envelopes</strong> — deleted after 30 days, whether collected or not.</li>
<li><strong>Attachments</strong> — deleted after 30 days.</li>
<li><strong>Used one-time prekeys</strong> — deleted after 7 days.</li>
<li><strong>Unencrypted messages, once read</strong> — deleted after 30 days.</li>
<li><strong>Unencrypted messages, regardless</strong> — deleted after 90 days.</li>
</ul>
<p>
An operator running their own server can configure these. The figures above are the
defaults and what this instance runs.
</p>
<h2>Account information</h2>
<p>
An account holds a username, an email address, and a password hash. Each device you
register publishes its public keys. Private keys are generated on your device and
are never transmitted.
</p>
<h2>Unencrypted conversations</h2>
<p>
Messages in a conversation with encryption turned off are stored encrypted at rest,
under a key held by the operator. That protects against a stolen database copy on
its own. It does not protect against the operator, and it does not protect against a
lawful demand made to the operator. Treat those conversations as readable by whoever
runs the server.
</p>
<h2>This website</h2>
<p>
No cookies are set, no analytics are collected, and no third-party scripts are
loaded. The page is served under a Content Security Policy that would break it if
one were ever added.
</p>
<h2>Your choices</h2>
<p>
You can delete a device, which removes its keys and any queued messages for it. You
can delete your account, which removes the account record and its devices. Messages
already delivered to someone else's device are on their device and cannot be
recalled by us.
</p>
<h2>Contact</h2>
<p>
Privacy questions: <a href="mailto:privacy@flamenetmessenger.com">privacy@flamenetmessenger.com</a>.
Security reports: see the <a href="/security">security page</a>.
</p>
</div>
</article>
#endexport
#endextend
+205
View File
@@ -0,0 +1,205 @@
#extend("layout"):
#export("content"):
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-relay">flamenet-relay</h1>
<p>The <code>/e2e/*</code> half of Flamenet Messenger as a standalone
service, so the message relay is a standalone service.</p>
<p>It stores public keys and opaque ciphertext. It holds no private key,
and there is no path in it that could decrypt an envelope if it wanted
to: <code>payload</code> goes into a column and comes back out
unread.</p>
<p>It is <strong>not</strong> an identity provider. Accounts, profiles
and the community they are anchored in are the relay's own, and it mints
the tokens it verifies against a <strong>pinned</strong> public key and
never calls on the request path.</p>
<h2 id="running-it">Running it</h2>
<pre class="bash"><code>swift build -c release
FN_ISSUER_PUBLIC_KEY=&lt;base64 Ed25519 public key&gt; \
FN_RELAY_AUDIENCE=https://relay.flamenet.io \
FN_DB=/var/lib/flamenet/relay.sqlite \
.build/release/flamenet-relay</code></pre>
<p>It refuses to start without an issuer key. There is no safe default
for that value, and a relay that accepts unsigned tokens is worse than
one that is down.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_ISSUER_PUBLIC_KEY</code></td>
<td>—</td>
<td>Required. Base64 Ed25519 public key of the token issuer.</td>
</tr>
<tr>
<td><code>FN_RELAY_AUDIENCE</code></td>
<td>—</td>
<td>Required. The <code>aud</code> this relay accepts.</td>
</tr>
<tr>
<td><code>FN_DB</code></td>
<td><code>relay.sqlite</code></td>
<td>SQLite file.</td>
</tr>
<tr>
<td><code>FN_HOST</code> / <code>FN_PORT</code></td>
<td><code>127.0.0.1</code> / <code>8080</code></td>
<td>Bind address.</td>
</tr>
<tr>
<td><code>FN_WORKDIR</code></td>
<td>inferred</td>
<td>Directory containing <code>Resources/</code> and
<code>Public/</code>. Set it in a unit file.</td>
</tr>
<tr>
<td><code>FN_CLOCK_SKEW</code></td>
<td><code>60</code></td>
<td>Seconds of tolerance on a proof timestamp.</td>
</tr>
<tr>
<td><code>FN_BUNDLE_RATE</code> /
<code>FN_BUNDLE_RATE_TARGET</code></td>
<td><code>120</code> / <code>6</code></td>
<td>Bundle fetches per hour, per requester and per pair.</td>
</tr>
<tr>
<td><code>FN_SEND_RATE</code></td>
<td><code>600</code></td>
<td>Envelopes per hour.</td>
</tr>
<tr>
<td><code>FN_DELIVERED_RETENTION_DAYS</code></td>
<td><code>1</code></td>
<td></td>
</tr>
<tr>
<td><code>FN_UNDELIVERED_RETENTION_DAYS</code></td>
<td><code>30</code></td>
<td>Must stay below the client's retired-SPK retention (45 days).</td>
</tr>
<tr>
<td><code>FN_ATTACHMENTS_DIR</code></td>
<td><code>attachments</code></td>
<td>Blob directory. Relative paths resolve under
<code>FN_WORKDIR</code>.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_MAX_BYTES</code></td>
<td><code>10485760</code></td>
<td>10 MiB per blob.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_RATE</code> /
<code>FN_ATTACHMENT_BYTES_RATE</code></td>
<td><code>60</code> / <code>200 MiB</code></td>
<td>Per uploader, per hour.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_RETENTION_DAYS</code></td>
<td><code>30</code></td>
<td></td>
</tr>
<tr>
<td><code>FN_TURN_SECRET</code> / <code>FN_TURN_HOST</code></td>
<td>unset</td>
<td>Unset means <code>GET /e2e/turn</code> answers 503 — calling is off
on this instance, which is a different thing from not permitted.</td>
</tr>
<tr>
<td><code>FN_TURN_TTL</code></td>
<td><code>7200</code></td>
<td></td>
</tr>
</tbody>
</table>
<h2 id="auth">Auth</h2>
<p>Two modes, deliberately.</p>
<p><strong>Identified routes</strong> take
<code>Authorization: Bearer &lt;token&gt;</code> <em>plus</em> a
proof-of-possession signature. The token is not a bearer credential:</p>
<ul>
<li>scoped to <strong>one device</strong> (<code>dev</code>), so one
stolen from a browser cannot drain the phone's prekey pool or read its
inbox;</li>
<li>bound to <strong>one relay</strong> (<code>aud</code>), so a token
minted by one instance is not valid at another;</li>
<li>bound to the device's <code>IK_sig</code> (<code>cnf</code>), and
every request carries <code>X-FN-Timestamp</code>,
<code>X-FN-Nonce</code> and <code>X-FN-Signature</code> over
<code>method \n path \n timestamp \n nonce \n sha256(body)</code>. A
stolen token is inert without the key. Proofs are single-use within the
skew window.</li>
</ul>
<p>The issuer's verification key is <strong>configuration</strong>,
never fetched. Fetched over HTTPS, a compromised identity provider could
serve a key of its own and mint tokens this relay accepts — which is the
exact compromise the split exists to survive. Rotation is a deploy, on
purpose.</p>
<p><strong><code>POST /e2e/messages</code> is different.</strong> A
sealed submission presents the recipient's delivery key and is otherwise
unauthenticated, exactly as the earlier relay had it. Requiring a token
there would hand the relay the sender's identity at request time, so
sealing would hide the sender from the stored row and from nobody else.
Sealed traffic is metered by recipient and by IP, because there is no
sender to meter.</p>
<h2 id="routes">Routes</h2>
<p>All of SPEC.md §8: devices (§8.1), bundles (§8.2), device list
(§8.3), revocation (§8.3a), send and poll (§8.4/§8.5), prekey
replenishment (§8.6), sealed attachments (§8.7/§8.8), the group registry
(§8.9), and TURN credentials (§8.10).</p>
<p>Two deliberate deviations from what the earlier implementation did,
both forced by the same thing — this service is not the identity
provider:</p>
<ul>
<li><strong>Group members are ids only</strong>, with no
<code>name</code>. The earlier server joined a user table for a display
name. A relay that caches display names is a relay that leaks them;
clients resolve names against the portal, as they already do for buddy
lists.</li>
<li><strong>Group membership requires a reachable device</strong>,
rather than <code>get_userdata()</code>. The relay cannot answer "is
this a real account". It can answer "can this member receive a single
group envelope", which is stricter: fan-out is per device, so a member
with none is a silent black hole.</li>
</ul>
<h2 id="what-this-does-not-fix">What this does not fix</h2>
<p>Stated here because a rewrite implies it fixed everything it
touched.</p>
<ul>
<li><strong>Key transparency is still absent.</strong> This service is
still the sole distributor of prekey bundles; the split reduced its
power over key distribution by exactly zero. It is a precondition for
federation, not a follow-on.</li>
<li><strong>Group rosters</strong> are not implemented here yet, and
remain server-visible where they are.</li>
<li><strong>Profiles</strong> are server-visible.</li>
<li><strong>IdP compromise</strong> still buys envelope deletion, prekey
drain and traffic observation. A registration lock stops device
registration, not everything.</li>
<li><strong>Web client delivery.</strong> A separate origin removes
third-party scripts; it does not change who ships the code. On the web
the relay still serves the client, so "defends against a compromised
relay" holds on native and not in the browser.</li>
</ul>
<h2 id="tests">Tests</h2>
<p><code>node ../test/relay.mjs</code> boots this binary and drives it
over real HTTP against SPEC.md §8 — including the negative cases, which
are the point: a forged proof, a replayed proof, a token for another
relay, a token signed by an unpinned issuer, and a token used against a
device it was not scoped to.</p>
<p>It skips rather than fails when the binary is not built, so the JS
suite still runs without a Swift toolchain. 60 assertions, including a
path-traversal probe against the attachment id and a
cross-implementation check that the coturn REST credential this mints
matches one computed independently in Node.</p>
</div>
</article>
#endexport
#endextend
+62
View File
@@ -0,0 +1,62 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Roadmap</h1>
<p class="lede">
What is not done, in roughly the order it matters. Listed here rather than left for
someone to discover in the source. Items move to
<a href="/#("#")features">features</a> when they ship, not when they are started.
</p>
<h2>An Android client</h2>
<p>
iOS now carries the protocol in full: end-to-end encryption, post-quantum key
agreement and identity, key-log verification and gossip. It has still never shipped —
there is no signing team and no release channel — but the engine is there and is
checked against the web engine on every test run.
</p>
<p>
Android does not exist at all. Not a partial implementation: no engine, no client, no
interop harness entry. Until one exists, a conversation gets these guarantees only
when both ends are web or iOS, and the specification's status tables mark every
Android column accordingly rather than leaving it implied.
</p>
<h2>Private group membership</h2>
<p>
The relay stores group rosters and enforces them, so it knows exactly who is in every
group — the largest remaining metadata leak. The design that fixes it is now written
down in §22: membership as an anonymous credential the relay can verify without
enumerating. What §22 also records is the part that does not follow from it — fan-out
is per device, so a relay that cannot read a roster can still reconstruct one by
watching who receives the traffic. That second half is larger than the first.
</p>
<h2>Anonymous sender credentials</h2>
<p>
Sealed sending is gated by a delivery key, which is a shared secret: cutting off one
person means rotating for everyone. §21 specifies the construction that fixes it
properly, and — more usefully — records why the obvious shortcut is a trade rather
than a win. Per-contact keys would buy selective revocation at the cost of giving the
relay a stable pseudonym for each sender, which is a metadata regression. It is not
implemented for that reason, not because nobody thought of it.
</p>
<h2>External audit</h2>
<p>
No third-party audit has been done. This is the one item on this list that cannot be
closed by writing code — it needs a firm and a budget, which is what
<a href="/donate">donations</a> are for.
</p>
<p>
What has been done is the preparation: <a href="/audit">a scope document</a> setting
out the trust boundaries, the assets in order of consequence, what is deliberately
out of scope, and five falsifiable claims we would like attacked. Auditors spend
their first hours working out what a system is; that part is now given away rather
than paid for twice.
</p>
</div>
</article>
#endexport
#endextend
+80
View File
@@ -0,0 +1,80 @@
#extend("layout"):
#export("content"):
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="security">Security</h1>
<h2 id="status">Status</h2>
<p><strong>Not independently audited.</strong> This engine was written
and reviewed in-house. It has a conformance suite
(<code>test/conformance.mjs</code>) covering the constructions in
<code>docs/SPEC.md</code>, including cross-backend agreement, tamper
rejection and downgrade refusal. A passing test suite demonstrates the
code does what the spec says; it does not demonstrate the spec is right,
and it is not a substitute for review by people who break protocols for
a living.</p>
<p>The vendored ML-KEM-768 fallback
(<code>vendor/mlkem768-noble.js</code>, from
<code>@noble/post-quantum</code>) carries the same caveat — upstream
describes it as self-audited, not independently audited. See
<code>vendor/PROVENANCE.md</code>.</p>
<h2 id="reporting-a-vulnerability">Reporting a vulnerability</h2>
<p>Email <strong><a
href="mailto:security@flamenetmessenger.com">security@flamenetmessenger.com</a></strong>.
Please do not open a public issue for anything that affects
confidentiality or authentication. We will acknowledge within 72
hours.</p>
<h2 id="threat-model">Threat model</h2>
<p>The full model is <code>docs/SPEC.md</code> §11 and §12.1. In brief,
this engine defends against a <strong>malicious or compromised
relay</strong> reading message contents, and (in v2) against a
<strong>passive adversary recording traffic today to decrypt after a
quantum computer exists</strong>.</p>
<p>It does <strong>not</strong> defend against:</p>
<ul>
<li><strong>A compromised endpoint.</strong> Device keys live on the
device. Malware on it reads plaintext.</li>
<li><strong>Metadata collection.</strong> The relay sees who messages
whom, when, and roughly how much. Sealed sender hides the
<em>sender</em> from the stored row (§14), but not from the connection
that delivered it, and the first message to a new contact is necessarily
unsealed.</li>
<li><strong>A quantum adversary present during the exchange.</strong> v2
makes key agreement hybrid, but identity keys are still Ed25519, so an
adversary who can forge signatures in real time can still mount an
active MITM. Post-quantum <em>authentication</em> is unimplemented.</li>
<li><strong>A hostile server operator, when the client is a web page
served by that operator.</strong> This is structural, not a bug: the
relay ships the JavaScript that holds the keys, so it can ship different
JavaScript to one user. Native clients do not have this property. Any
deployment making confidentiality claims to users must be explicit about
which client those claims apply to.</li>
</ul>
<h2 id="deployment-notes">Deployment notes</h2>
<ul>
<li><strong>Persist state encrypted.</strong>
<code>DeviceIdentity.archive()</code> and
<code>E2ESession.archive()</code> both contain private key material.
<code>localStorage</code> is not an acceptable store.</li>
<li><strong>Keep the two prekey pools the same size.</strong> A PQ pool
smaller than the classical pool drains first and silently downgrades new
sessions to the last-resort key. §12.3.</li>
<li><strong>Pin <code>minProto</code> per device.</strong> Without it, a
relay can strip the v2 fields from a bundle and both honest parties will
negotiate v1. Pinning is what makes downgrade detectable; first contact
remains vulnerable until <code>pqMode</code> is <code>required</code>.
§12.7.</li>
<li><strong>Rate-limit prekey fetches.</strong>
<code>GET /e2e/keys</code> consumes a one-time prekey per device per
call, so an attacker can drain both pools cheaply. This is unfixed at
the protocol layer.</li>
<li><strong>Do not claim more than you can show.</strong> The spec's
§12.10 lists what must actually be run before the corresponding claims
are made. An earlier version of this project asserted cross-platform
interop in its spec that had never been performed and was, at the time,
impossible. Write claims after the runs, not before.</li>
</ul>
</div>
</article>
#endexport
#endextend
+132
View File
@@ -0,0 +1,132 @@
#extend("layout"):
#export("content"):
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
<p>Running your own relay is the point of this project, not a footnote
to it. There is no hosted service to sign up for, no account with us,
and nothing that phones home. Two containers and a config file.</p>
<h2 id="quick-start">Quick start</h2>
<pre class="bash"><code>git clone https://gitlab.com/paulhitt/flamenet-e2e
cd flamenet-e2e/server
cp .env.example .env</code></pre>
<p>Generate the three secrets <code>.env</code> asks for:</p>
<pre class="bash"><code>openssl rand -base64 32 #("#") POSTGRES_PASSWORD
openssl rand -base64 32 #("#") FN_AT_REST_SECRET
openssl genpkey -algorithm ed25519 -out issuer.pem
openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64 #("#") FN_ISSUER_PRIVATE_KEY
openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64 #("#") FN_ISSUER_PUBLIC_KEY</code></pre>
<p>Then:</p>
<pre class="bash"><code>docker compose up -d</code></pre>
<p>Open <a href="http://localhost:8080">http://localhost:8080</a>. The
relay serves its own landing page, so a working install explains itself
instead of answering with a 404.</p>
<h2 id="what-those-secrets-are">What those secrets are</h2>
<ul>
<li><strong><code>FN_ISSUER_PRIVATE_KEY</code> /
<code>FN_ISSUER_PUBLIC_KEY</code></strong> — an Ed25519 keypair. The
relay issues short-lived tokens signed with the private half and
verifies them with the public half, which is configuration and is never
fetched over the network. Keep <code>issuer.pem</code>: losing it
invalidates every token you have issued, and leaking it lets anyone mint
tokens your relay accepts.</li>
<li><strong><code>FN_AT_REST_SECRET</code></strong> — encrypts stored
message bodies for conversations where encryption is turned off. It
defends against a stolen database dump on its own. It does not defend
against you: you hold the key.</li>
<li><strong><code>POSTGRES_PASSWORD</code></strong> — the database. Not
published on the host by the compose file; the relay reaches it over the
compose network.</li>
</ul>
<h2 id="storage">Storage</h2>
<p>Postgres by default in the compose file. The relay also runs on
SQLite, which is what a single binary with no second process uses:</p>
<pre class="bash"><code>FN_ISSUER_PUBLIC_KEY=... FN_ISSUER_PRIVATE_KEY=... FN_AT_REST_SECRET=... \
flamenet-relay serve --hostname 0.0.0.0 --port 8080</code></pre>
<p>Set <code>FN_POSTGRES_URL</code> and it uses Postgres; leave it unset
and it writes <code>relay.sqlite</code>. The schema is identical either
way.</p>
<p>Attachments are files rather than rows, so they live on a volume in
both modes (<code>FN_ATTACHMENTS_DIR</code>,
<code>/data/attachments</code> in the container).</p>
<h2 id="putting-it-behind-tls">Putting it behind TLS</h2>
<p>The compose file binds to <code>127.0.0.1:8080</code> on purpose.
Terminate TLS in front of it with whatever you already run — Caddy,
nginx, Traefik — and point it at that port. Two things matter:</p>
<ul>
<li><strong>HTTPS is not optional in practice.</strong> Clients hold key
material; serving them over plain HTTP hands it to anyone on the
path.</li>
<li><strong>Forward the client address.</strong> Sealed submissions are
metered per recipient and per IP, and behind a proxy that does not set
<code>X-Forwarded-For</code> every request looks like it came from the
proxy.</li>
</ul>
<h2 id="retention">Retention</h2>
<p>The relay purges on a timer of its own, inside the process. Nothing
external has to be scheduled, and there is no cron to wedge. The
defaults:</p>
<table>
<thead>
<tr>
<th></th>
<th>Deleted after</th>
</tr>
</thead>
<tbody>
<tr>
<td>Delivered envelopes</td>
<td>1 day</td>
</tr>
<tr>
<td>Undelivered envelopes</td>
<td>30 days</td>
</tr>
<tr>
<td>Attachments</td>
<td>30 days</td>
</tr>
<tr>
<td>Used one-time prekeys</td>
<td>7 days</td>
</tr>
<tr>
<td>Unencrypted messages, once read</td>
<td>30 days</td>
</tr>
<tr>
<td>Unencrypted messages, regardless</td>
<td>90 days</td>
</tr>
</tbody>
</table>
<p>Override any of them with <code>FN_DELIVERED_RETENTION_DAYS</code>,
<code>FN_UNDELIVERED_RETENTION_DAYS</code>,
<code>FN_ATTACHMENT_RETENTION_DAYS</code>,
<code>FN_READ_MESSAGE_RETENTION_DAYS</code>,
<code>FN_MESSAGE_RETENTION_DAYS</code>.</p>
<h2 id="voice-calls">Voice calls</h2>
<p>Optional, and only needed for calls. Set <code>FN_TURN_HOST</code>
and <code>FN_TURN_SECRET</code> to a coturn instance using the standard
REST-secret scheme. The relay mints credentials and never proxies media.
Messaging works without any of it.</p>
<h2 id="verifying-what-you-are-running">Verifying what you are
running</h2>
<pre class="bash"><code>engine/scripts/verify-build.sh #("#") digests of the engine sources
engine/scripts/verify-build.sh --rebuild #("#") prove the vendored ML-KEM rebuilds byte-identically</code></pre>
<h2 id="what-self-hosting-actually-buys-you">What self-hosting actually
buys you</h2>
<p>It removes the operator from your threat model, because you are the
operator. Nobody else holds the at-rest key, nobody else can be asked
for your data, and nobody else decides when to retain it.</p>
<p>It does <strong>not</strong> make you anonymous to the people you
talk to, and it does not change what any relay necessarily sees:
recipients, timing, and approximate sizes. It also does not solve key
distribution — your relay hands out prekey bundles, and safety numbers
remain the check that does not require trusting it.</p>
</div>
</article>
#endexport
#endextend
+40
View File
@@ -0,0 +1,40 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Source code</h1>
<p class="lede">
The engine and the relay are published under the MIT licence. Everything this site
describes is in one repository.
</p>
<h2>What is in it</h2>
<ul>
<li><strong>engine/</strong> — the JavaScript cryptographic engine, its test suite, and the published test vectors</li>
<li><strong>engine/docs/SPEC.md</strong> — the protocol <a href="/spec">specification</a>, rendered on this site</li>
<li><strong>server/</strong> — the Swift relay that serves this page</li>
</ul>
<h2>Reading it without cloning it</h2>
<p>
The documents that matter are on this site, generated from the same markdown the
repository ships: the <a href="/spec">specification</a>,
the <a href="/security">security notes</a>, the
<a href="/self-hosting">self-hosting guide</a>, and the
<a href="/engine">engine</a> and <a href="/relay">relay</a> overviews.
</p>
<h2>The repository</h2>
<p>
<a href="https://gitlab.com/paulhitt/flamenet-e2e">gitlab.com/paulhitt/flamenet-e2e</a>
</p>
<p>
That and the hosting credit in the footer are the only two 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.
</p>
</div>
</article>
#endexport
#endextend
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
#extend("layout"):
#export("content"):
<article class="band doc">
<div class="wrap wrap-narrow">
<h1>Terms of Use</h1>
<p class="meta">These terms cover the server operated at this domain. The software itself is MIT licensed — see <a href="/licenses">licences</a>.</p>
<h2>The service</h2>
<p>
This server carries messages between devices. It is provided as it is, without a
warranty and without a promise about uptime. It is a small independent service
rather than an enterprise product, and we would rather set that expectation up
front.
</p>
<h2>Your account</h2>
<p>
You are responsible for your password and for the devices you register. Because
the keys live on your devices, we cannot recover your messages if you lose them.
That is how the design works rather than something we have overlooked.
</p>
<h2>Acceptable use</h2>
<p>Do not use this service to:</p>
<ul>
<li>break the law in your jurisdiction or ours</li>
<li>harass, threaten, or stalk anyone</li>
<li>distribute malware, or attack this service or any other</li>
<li>send bulk unsolicited messages</li>
</ul>
<p>
We cannot read encrypted conversations, so we cannot moderate them. That is very
much the intent of the design, and it is also its limit: anything we act on has to
come from a report, or from what the server can actually see.
</p>
<h2>Suspension</h2>
<p>
Accounts may be suspended for abuse of the service or its infrastructure. Where
practical we will say why.
</p>
<h2>Availability and changes</h2>
<p>
The service may change or stop. If this instance is going to shut down, we will give
notice on this site. Because the server is open source and self-hostable, shutting
down here does not end the software.
</p>
<h2>Liability</h2>
<p>
To the maximum extent the law allows, the operator is not liable for indirect or
consequential losses arising from use of this service. Nothing here limits liability
that cannot lawfully be limited.
</p>
<h2>Contact</h2>
<p><a href="mailto:legal@flamenetmessenger.com">legal@flamenetmessenger.com</a></p>
</div>
</article>
#endexport
#endextend
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Render the project's markdown into Leaf pages served by the relay.
#
# The site documents itself rather than sending people to a code host, so these
# pages are generated from the same markdown the repository ships. Re-run after
# editing any source document:
#
# Scripts/build-doc-pages.sh
#
# Requires pandoc. The output is committed, so building the relay does not.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo="$(cd "$here/.." && pwd)"
views="$here/Resources/Views"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
command -v pandoc >/dev/null || { echo "pandoc is required" >&2; exit 1; }
render() {
local slug="$1" src="$2"
[ -f "$src" ] || { echo "missing source: $src" >&2; exit 1; }
pandoc --from=gfm --to=html5 --syntax-highlighting=none "$src" > "$tmp/$slug.html"
python3 "$here/Scripts/md2leaf.py" "$slug" "$tmp/$slug.html" "$views"
}
echo "rendering doc pages:"
render spec "$repo/engine/docs/SPEC.md"
render audit "$repo/engine/docs/AUDIT-SCOPE.md"
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"
echo "done."
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Wrap a pandoc HTML fragment as a Leaf page that extends the site layout.
Usage: md2leaf.py <slug> <html-fragment-file> <views-dir>
"""
import sys
slug, src, views = sys.argv[1], sys.argv[2], sys.argv[3]
body = open(src, encoding="utf-8").read()
# The documents refer to the deployed site absolutely. On the site itself that
# is a link that leaves and comes back, so it is rewritten to a relative one.
body = body.replace('href="https://flamenetmessenger.com"', 'href="/"')
body = body.replace('href="https://flamenetmessenger.com/', 'href="/')
# Leaf treats `#` as the start of a tag, and these documents are full of them:
# heading anchors, shell comments, `#8` cross-references. Escape every one to a
# literal. Single pass, so the `#` this inserts is not itself re-scanned.
body = body.replace("#", '#("#")')
out = (
'#extend("layout"):\n'
'#export("content"):\n'
'<article class="band doc doc-generated">\n'
' <div class="wrap wrap-narrow">\n'
f"{body}\n"
" </div>\n"
"</article>\n"
"#endexport\n"
"#endextend\n"
)
open(f"{views}/{slug}.leaf", "w", encoding="utf-8").write(out)
print(f" {slug}.leaf ({len(body.splitlines())} lines)")
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Refuse to publish anything that looks like a credential.
#
# This subtree is mirrored to a PUBLIC repository, and a public git history is
# not something you can take back — a leaked key is leaked the moment the push
# lands, regardless of what the next commit does. So this runs as a gate before
# the mirror, not as a report afterwards.
#
# scripts/secret-sweep.sh [dir] # defaults to the package root
#
# Exits non-zero on the first credible hit. False positives are expected and are
# the correct trade here: the cost of one is a person reading a line of output,
# and the cost of a miss is a rotation and an incident.
set -euo pipefail
ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$ROOT"
# Build products and vendored upstream are excluded: .build is compiler output,
# and vendor/ is checked separately by the supply-chain digest test. This script
# excludes ITSELF by filename rather than by one hardcoded path -- it is the file
# with every pattern in it, so a copy living anywhere else reports itself as a
# find, and a repo with a `Scripts/` rather than a `scripts/` did exactly that.
# `git ls-files .` and not plain `git ls-files`: this subtree may be untracked
# in the repo that contains it, and an unscoped call there returns the WHOLE
# repository. An empty result means untracked, not empty — fall back to find,
# or the sweep silently passes by looking at nothing, which is worse than not
# running it at all.
FILES=$(git ls-files . 2>/dev/null || true)
if [ -z "$FILES" ]; then
FILES=$(find . -type f -not -path '*/.build/*' -not -path '*/node_modules/*' -not -path '*/.git/*')
fi
hits=0
probe() {
local label="$1" pattern="$2"
local found
found=$(printf '%s\n' "$FILES" \
| grep -vE '(^|/)vendor/|\.build/|secret-sweep\.sh$' \
| xargs -I{} grep -HnE "$pattern" {} 2>/dev/null || true)
if [ -n "$found" ]; then
echo "── $label"
printf '%s\n' "$found" | head -8
hits=$((hits + 1))
fi
}
# Shapes, not names: a variable called API_KEY holding "" is fine, and a
# variable called `x` holding a live token is not.
probe "Stripe live key" 'sk_live_[0-9A-Za-z]{16,}'
probe "GitLab token" 'glpat-[0-9A-Za-z_-]{20,}'
probe "AWS access key id" 'AKIA[0-9A-Z]{16}'
probe "Anthropic key" 'sk-ant-[0-9A-Za-z_-]{20,}'
probe "Google API key" 'AIza[0-9A-Za-z_-]{35}'
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,}["'"'"']'
if [ "$hits" -gt 0 ]; then
echo
echo "secret sweep FAILED: $hits pattern(s) matched. Nothing was published."
exit 1
fi
echo "secret sweep clean ($(printf '%s\n' "$FILES" | wc -l | tr -d ' ') files)"
+142
View File
@@ -0,0 +1,142 @@
import Fluent
import SQLKit
import Foundation
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
/// 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 {
static let schema = "users"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "username") var username: String
/// Optional, because an account authenticated by the shared FlameNet
/// account service has no email here and storing one it never uses would
/// be a field that exists only to leak. Empty string is not an option: the
/// column is unique, so the first such account would take `""` and every
/// one after it would collide.
@OptionalField(key: "email") var email: String?
@Field(key: "password_hash") var passwordHash: String
@Field(key: "display_name") var displayName: String
/// Presence. `chosenStatus` is what the person picked; `lastSeenAt` is a
/// heartbeat rounded down to the online window, because the only question
/// asked of it is "within the last five minutes?" and second precision
/// would keep a finer activity log than that question needs.
@Field(key: "chosen_status") var chosenStatus: String
/// The FlameNet account this user is, when they signed in with one.
///
/// A **product-scoped pseudonym**: an opaque UUID the account service
/// derives per product, so this relay cannot correlate its users with
/// Files', and learns nothing about the FlameNet handle behind it. Null
/// for accounts created directly here, which is what a self-hosted relay
/// has.
@OptionalField(key: "enclave_pseudonym") var enclavePseudonym: String?
@OptionalField(key: "last_seen_at") var lastSeenAt: Date?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
/// Seeds the columns that are NOT NULL but that a caller has no reason to
/// think about.
///
/// Fluent writes every property explicitly on insert, so a property nobody
/// set is written as NULL and the column's database default never applies.
/// `signup` happened to assign this one and the shared-account bridge did
/// not, which made creating an account through the bridge a 500 -- on a
/// fresh database, so it failed for exactly the people setting one up.
init() {
self.chosenStatus = ChosenStatus.online.rawValue
}
/// Usernames are compared case-insensitively and stored lowercased.
///
/// Two accounts differing only in case are an impersonation primitive in a
/// product whose whole identity story is "you know who you are talking to
/// because you have known them on the forum for years".
static func normalize(_ username: String) -> String {
username.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
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 `-`
// 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.
return u.allSatisfy { $0.isLowercase || $0.isNumber }
}
}
/// A long-lived credential the client stores and presents to get short-lived
/// relay tokens.
///
/// This is the port of the plugin's Application Password: the native app
/// exchanged a username and password for one of these once, then never handled
/// the password again. Stored as a hash — a database read must not yield a
/// usable credential, which is the whole reason it is not just a random column.
final class AppCredential: Model, @unchecked Sendable {
static let schema = "app_credentials"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
/// First 12 chars of the secret, stored in the clear purely as a lookup
/// key. Without it, verifying a credential means bcrypt-comparing against
/// every row in the table.
@Field(key: "selector") var selector: String
@Field(key: "secret_hash") var secretHash: String
@Field(key: "label") var label: String
@OptionalField(key: "last_used_at") var lastUsedAt: Date?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
struct CreateUser: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(User.schema)
.field("id", .int, .identifier(auto: true))
.field("username", .string, .required)
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("display_name", .string, .required)
// Presence lives on the user row. Folded into the create rather
// than added later: SQLite permits one ADD COLUMN per ALTER, so a
// multi-field .update() is a syntax error there — and with nothing
// deployed there is no history worth preserving.
.field("chosen_status", .string, .required, .sql(.default("online")))
.field("last_seen_at", .datetime)
.field("created_at", .datetime)
.unique(on: "username")
.unique(on: "email")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(User.schema).delete()
}
}
struct CreateAppCredential: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(AppCredential.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("selector", .string, .required)
.field("secret_hash", .string, .required)
.field("label", .string, .required)
.field("last_used_at", .datetime)
.field("created_at", .datetime)
.unique(on: "selector")
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS app_credentials_user ON app_credentials(user_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(AppCredential.schema).delete()
}
}
+100
View File
@@ -0,0 +1,100 @@
import Crypto
import Foundation
/// At-rest encryption for stored message bodies.
///
/// This is **not** the end-to-end layer. There, the server never holds a key
/// and an encrypted thread writes no message row at all. This covers the other
/// case — a conversation with encryption turned off, stored so search and
/// previews can work.
///
/// What it defends against: a database dump on its own. The key lives in
/// configuration, so the table alone does not read.
///
/// What it does **not** defend against: us. We hold the key. A subpoena, an
/// operator, or anyone with both the dump and the config reads every row. Any
/// claim that there is "no decryptable archive to breach, subpoena, or mine" is
/// false for as long as this table has rows in it, and that is a statement
/// about the marketing copy as much as about the code.
///
/// 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
/// **not** what `AES.GCM.SealedBox.combined` produces, which puts the tag last.
/// Assembling by hand is deliberate, not an oversight.
enum AtRestCrypto {
/// iv(12) + tag(16) + at least one byte of ciphertext.
static let minimumBlobBytes = 29
/// Derive the 32-byte key exactly as the PHP did — `sha256("fnmsg-" || secret)`
/// truncated to 32 bytes — so a migrated row opens under the same secret.
static func key(from secret: String) -> SymmetricKey {
let digest = SHA256.hash(data: Data("fnmsg-\(secret)".utf8))
return SymmetricKey(data: Data(digest))
}
/// Seal a body.
///
/// The PHP returned the plaintext unchanged when OpenSSL was missing,
/// reasoning that losing a message is worse than storing it as it was
/// stored yesterday. That fallback is not carried over: AES-GCM is not
/// optional in swift-crypto, so the branch could only ever fire on a
/// genuine fault — and silently writing plaintext into a column everything
/// downstream believes is sealed is a worse failure than a refused send.
static func seal(_ plaintext: String, using key: SymmetricKey) throws -> String {
let nonce = AES.GCM.Nonce()
let box = try AES.GCM.seal(Data(plaintext.utf8), using: key, nonce: nonce)
var blob = Data(nonce)
blob.append(box.tag)
blob.append(box.ciphertext)
return blob.base64EncodedString()
}
/// Open a stored body.
///
/// Anything that is not one of our envelopes comes back untouched. Rows
/// written before at-rest encryption shipped are plaintext, and a body that
/// will not decrypt should still render as itself rather than as base64
/// noise in somebody's conversation.
///
/// The cost of that leniency is that it cannot distinguish "this was never
/// encrypted" from "this will not decrypt any more" — a rotated secret
/// looks exactly like a legacy row. Rotating the secret without re-sealing
/// the table therefore turns every message into gibberish that renders,
/// rather than an error that reports.
static func open(_ stored: String, using key: SymmetricKey) -> String {
guard !stored.isEmpty else { return "" }
guard let raw = Data(base64Encoded: stored), raw.count >= minimumBlobBytes else {
return stored // never encrypted
}
let nonceBytes = raw.prefix(12)
let tag = raw.dropFirst(12).prefix(16)
let ciphertext = raw.dropFirst(28)
guard let nonce = try? AES.GCM.Nonce(data: nonceBytes),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag),
let plain = try? AES.GCM.open(box, using: key),
let text = String(data: plain, encoding: .utf8)
else {
// Either a plaintext body that happened to be valid base64, or a
// real failure: a rotated secret, or a corrupt row.
return stored
}
return text
}
}
/// The at-rest key, carried in a form that is `Sendable` on every platform.
///
/// Apple's CryptoKit marks `SymmetricKey` `Sendable`; swift-crypto does not, so
/// a `Sendable` struct storing one compiles on macOS and fails on Linux under
/// Swift 6 concurrency checking. Holding the raw bytes and materialising the
/// key at the use site is portable, and the key is read-only anyway.
struct AtRestKey: Sendable {
private let raw: Data
init(secret: String) {
self.raw = Data(SHA256.hash(data: Data("fnmsg-\(secret)".utf8)))
}
var key: SymmetricKey { SymmetricKey(data: raw) }
}
+146
View File
@@ -0,0 +1,146 @@
import Crypto
import Foundation
/// Everything the relay needs to run, read once at startup from the
/// environment. No config is fetched over the network — see `issuerKey`.
struct Config: Sendable {
let host: String
let port: Int
let databasePath: String
/// Postgres connection URL. When set it replaces the SQLite file entirely.
let postgresURL: String?
/// 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.
let donateProvider: String?
let donateSecret: String?
/// The issuer's Ed25519 public key, as raw bytes.
///
/// Bytes rather than a `Curve25519.Signing.PublicKey`, because on Linux
/// swift-crypto's key types are not `Sendable` while Darwin's CryptoKit
/// ones are — so a struct holding one cannot be `Sendable` on the platform
/// this actually deploys to. Reconstructing the key per verification is a
/// 32-byte load next to a signature check, and `@unchecked Sendable` here
/// would be silencing the compiler about the one type in this file that
/// crosses threads.
let issuerKeyRaw: Data
let audience: String
let clockSkew: Int
/// Bundle fetches per requester, and per requester-target pair, per hour.
let bundleRatePerHour: Int
let bundleRatePerTarget: Int
/// Envelopes per sender (or per recipient, when sealed) per hour.
let sendRatePerHour: Int
let deliveredRetentionDays: Int
let undeliveredRetentionDays: Int
/// Attachments (spec §8.7).
let attachmentsDirectory: String
let attachmentMaxBytes: Int
let attachmentRatePerHour: Int
let attachmentBytesPerHour: Int
let attachmentRetentionDays: Int
/// TURN (spec §8.10). Absent means calling is not configured on this
/// instance, and the route says so with a 503 rather than minting
/// credentials nothing will accept.
let turnSecret: String?
let turnHost: String?
let turnTTL: Int
/// The built-in identity provider's Ed25519 signing seed, or nil.
///
/// Nil on flamenet.io, where WordPress 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.
let issuerSigningKeyRaw: Data?
let tokenLifetime: Int
/// Secret the at-rest message key is derived from.
///
/// Defaults to the issuer audience, which is stable per deployment and
/// wrong as a secret — but the plaintext path is opt-in and a relay that
/// refuses to boot over a feature nobody enabled is worse. Set
/// FN_AT_REST_SECRET before storing anything on that path.
let atRestSecret: String
/// How long the non-E2E path keeps a message. The plugin kept them forever.
let messageRetentionDays: Int
let readMessageRetentionDays: Int
enum ConfigError: Error, CustomStringConvertible {
case missing(String)
case badIssuerKey
var description: String {
switch self {
case .missing(let k): return "\(k) is not set"
case .badIssuerKey:
return "FN_ISSUER_PUBLIC_KEY must be a base64 32-byte Ed25519 public key"
}
}
}
static func fromEnvironment(_ env: [String: String] = ProcessInfo.processInfo.environment)
throws -> Config
{
// The issuer's verification key is CONFIGURATION, deliberately, and is
// never fetched from the issuer.
//
// Fetching it over HTTPS would mean a compromised identity provider
// could serve a key of its own choosing and then mint tokens this relay
// accepts — which is precisely the compromise the split exists to
// survive. Pinned here, an IdP takeover cannot forge a token; it can
// still do plenty (see the residuals in the architecture note), but not
// that. Rotation is a deploy, on purpose: it should be an act, not an
// automatic consequence of whatever the IdP is currently serving.
guard let keyB64 = env["FN_ISSUER_PUBLIC_KEY"], !keyB64.isEmpty else {
throw ConfigError.missing("FN_ISSUER_PUBLIC_KEY")
}
guard let raw = Data(base64Encoded: keyB64),
(try? Curve25519.Signing.PublicKey(rawRepresentation: raw)) != nil
else { throw ConfigError.badIssuerKey }
guard let audience = env["FN_RELAY_AUDIENCE"], !audience.isEmpty else {
throw ConfigError.missing("FN_RELAY_AUDIENCE")
}
func int(_ key: String, _ fallback: Int) -> Int {
env[key].flatMap(Int.init) ?? fallback
}
return Config(
host: env["FN_HOST"] ?? "127.0.0.1",
port: int("FN_PORT", 8080),
databasePath: env["FN_DB"] ?? "relay.sqlite",
postgresURL: env["FN_POSTGRES_URL"].flatMap { $0.isEmpty ? nil : $0 },
donateProvider: env["FN_DONATE_PROVIDER"].flatMap { $0.isEmpty ? nil : $0 },
donateSecret: env["FN_DONATE_SECRET"].flatMap { $0.isEmpty ? nil : $0 },
issuerKeyRaw: raw,
audience: audience,
clockSkew: int("FN_CLOCK_SKEW", 60),
bundleRatePerHour: int("FN_BUNDLE_RATE", 120),
bundleRatePerTarget: int("FN_BUNDLE_RATE_TARGET", 6),
sendRatePerHour: int("FN_SEND_RATE", 600),
deliveredRetentionDays: int("FN_DELIVERED_RETENTION_DAYS", 1),
undeliveredRetentionDays: int("FN_UNDELIVERED_RETENTION_DAYS", 30),
attachmentsDirectory: env["FN_ATTACHMENTS_DIR"] ?? "attachments",
attachmentMaxBytes: int("FN_ATTACHMENT_MAX_BYTES", 10 * 1024 * 1024),
attachmentRatePerHour: int("FN_ATTACHMENT_RATE", 60),
attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024),
attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30),
turnSecret: env["FN_TURN_SECRET"].flatMap { $0.isEmpty ? nil : $0 },
turnHost: env["FN_TURN_HOST"].flatMap { $0.isEmpty ? nil : $0 },
turnTTL: int("FN_TURN_TTL", 7200),
issuerSigningKeyRaw: env["FN_ISSUER_PRIVATE_KEY"]
.flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) },
tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime),
atRestSecret: env["FN_AT_REST_SECRET"] ?? audience,
messageRetentionDays: int("FN_MESSAGE_RETENTION_DAYS", 90),
readMessageRetentionDays: int("FN_READ_MESSAGE_RETENTION_DAYS", 30)
)
}
}
+74
View File
@@ -0,0 +1,74 @@
import Fluent
import Foundation
import SQLKit
import Vapor
/// Per-viewer state for one conversation. Two people, two rows: starring and
/// archiving are personal, and always were.
///
/// The plugin also carried `is_encrypted` alongside `enc_mode`. Its own
/// migration note says `enc_mode` replaced it, because a tinyint cannot tell
/// "nobody has chosen" from "somebody chose plain". Only `enc_mode` is ported:
/// carrying a redundant column that the original already calls redundant is
/// exactly the metadata not to keep.
final class ConversationState: Model, @unchecked Sendable {
static let schema = "conversation_state"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
@Field(key: "other_id") var otherID: Int
@Field(key: "starred") var starred: Bool
@Field(key: "archived") var archived: Bool
/// "", "enc" or "plain" — empty means nobody has chosen.
@Field(key: "enc_mode") var encMode: String
/// Messages at or below this id are hidden from this viewer.
@Field(key: "cleared_to_id") var clearedToID: Int
/// When this viewer accepted a first contact from `other_id`.
///
/// Nil, and not a buddy, means messages from them are **requests** rather
/// than inbox items. This is the anti-harassment primitive: first contact
/// from an unknown sender is gated instead of landing in the conversation
/// list. Derived rather than flagged per message — a `pending` column on
/// every row would be the same fact stored a thousand times.
@OptionalField(key: "accepted_at") var acceptedAt: Date?
/// Per-conversation message lifetime, in seconds. Nil means keep.
///
/// Every real-world compromise of a messenger like this one has been device
/// access rather than broken cryptography, which makes this the highest-
/// leverage thing in the product for actual user safety — more than another
/// round of protocol work. It is deliberately per conversation rather than
/// global: the threat is specific to who you are talking to.
@OptionalField(key: "disappear_after") var disappearAfter: Int?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
init() {}
}
struct CreateConversationState: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(ConversationState.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("other_id", .int, .required)
.field("starred", .bool, .required, .sql(.default(false)))
.field("archived", .bool, .required, .sql(.default(false)))
.field("enc_mode", .string, .required, .sql(.default("")))
.field("cleared_to_id", .int, .required, .sql(.default(0)))
.field("accepted_at", .datetime)
.field("disappear_after", .int)
.field("updated_at", .datetime)
.unique(on: "user_id", "other_id")
.create()
try await (database as? any SQLDatabase)?.raw(
"CREATE INDEX IF NOT EXISTS convo_user ON conversation_state(user_id, archived, starred)"
).run()
}
func revert(on database: any Database) async throws {
try await database.schema(ConversationState.schema).delete()
}
}
// The expiry index lives with the messages table; see CreateMessage.
+658
View File
@@ -0,0 +1,658 @@
import Crypto
import Fluent
import FluentSQLiteDriver
import Foundation
import FluentPostgresDriver
import Leaf
import Vapor
/// Flamenet relay — the `/e2e/*` half of the messenger, as a standalone
/// service.
///
/// It stores public keys and opaque ciphertext. It holds no private key, and
/// there is no path here that could decrypt an envelope if it wanted to:
/// `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
/// verifies against a pinned public key and never calls on the request path.
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
do {
try await configure(app)
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}
func configure(_ app: Application) async throws {
let config: Config
do {
config = try Config.fromEnvironment()
} catch {
// Refusing to start beats starting with a default that would make the
// isolation decorative. An unset issuer key has no safe fallback, and a
// relay that accepts unsigned tokens is worse than one that is down.
app.logger.critical("configuration: \(error)")
throw error
}
app.http.server.configuration.hostname = config.host
app.http.server.configuration.port = config.port
app.http.server.configuration.serverName = "flamenet-relay"
// Envelopes are small and capped at 50 per request; anything larger is not
// a message. Vapor's default is 16 MB, which is a lot of memory to let an
// unauthenticated sealed submission ask for.
app.routes.defaultMaxBodySize = ByteCount(value: Routes.maxBodyBytes)
// Logging is metadata, so the default is the quietest level that still
// reports a relay in trouble: `error`, which emits errors and criticals and
// nothing else.
//
// The reasoning runs downwards. At `debug` Vapor logs every request URL and
// every SQL statement — and a §8 URL carries a device id, so a debug-level
// relay keeps a durable record of who fetched whose bundle and when, in a
// file nothing sweeps. `info` is quieter but still narrates start-up,
// retention sweeps and per-request notices, which is operational detail
// nobody reads on a healthy day and one more place for something
// identifying to appear by accident.
//
// Anything above the floor has to be asked for: `FN_LOG_LEVEL=info` for a
// noisy deploy, `debug` for the length of an investigation and no longer.
if let raw = Environment.get("FN_LOG_LEVEL"), let level = Logger.Level(rawValue: raw) {
app.logger.logLevel = level
} else if app.logger.logLevel < .error {
app.logger.logLevel = .error
}
// Order matters and is easy to get backwards: `use` appends, and the FIRST
// middleware added is the OUTERMOST. Vapor's ErrorMiddleware has to be
// outside ours, so that a WireError is rendered by ours on the way out
// rather than being converted to a generic 500 before it ever gets there.
app.middleware = .init()
app.middleware.use(ErrorMiddleware.default(environment: app.environment))
app.middleware.use(SecurityHeadersMiddleware())
app.middleware.use(WireErrorMiddleware())
// SQLite by default: one file, no second process, which is what makes
// "run your own relay" a five-minute job rather than a deployment.
// Postgres when a URL is configured, for an instance that outgrows it.
//
// The schema is the same either way — Fluent migrations build both, and the
// one piece of hand-written SQL (the atomic prekey claim) uses dialect-aware
// binds and boolean literals so it runs unchanged on each.
if let url = config.postgresURL {
try app.databases.use(.postgres(url: url), as: .psql)
app.logger.info("database: postgres")
} else {
app.databases.use(.sqlite(.file(config.databasePath)), as: .sqlite)
app.logger.info("database: sqlite", metadata: ["path": .string(config.databasePath)])
}
app.migrations.add(
CreateDevice(), CreatePreKey(), CreatePQPreKey(), CreateEnvelope(),
CreateGroup(), CreateGroupMember(), CreateAttachment(), CreateDeliveryKey(),
CreateUser(), CreateAppCredential(),
CreateBuddy(), CreateMessage(), CreateConversationState(),
CreateBlock(), CreateNotification(), CreateLogEntry(),
// Last: it alters tables the migrations above create, and Fluent runs
// them in registration order.
TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail())
// 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.
try await app.autoMigrate()
// 3. Where the templates and static files live.
//
// Vapor infers a working directory from how the process was launched, which
// is fine for `swift run` and wrong for everything else — a systemd unit or
// a container starts the binary from somewhere with no Resources/ under it,
// and the landing page 500s while the API looks perfect. Explicit wins.
if let root = ProcessInfo.processInfo.environment["FN_WORKDIR"] {
app.directory = DirectoryConfiguration(workingDirectory: root)
}
app.views.use(.leaf)
// Static assets for the landing page. Nothing under Public/ is reachable by
// any /e2e/* route, and no /e2e/* handler reads from disk.
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
let routes = Routes(
config: config,
verifier: TokenVerifier(
issuerKeyRaw: config.issuerKeyRaw,
audience: config.audience,
skew: config.clockSkew),
limits: RateLimiter(),
replay: ReplayCache(window: config.clockSkew * 2)
)
try register(routes: routes, on: app)
// The non-E2E messenger surface. Keyed from the same secret as everything
// else here; a deployment that never enables the plaintext path still gets
// the routes, and they simply have nothing to open.
let messaging = MessagingRoutes(
auth: routes,
atRestKey: AtRestKey(secret: config.atRestSecret),
limits: RateLimiter())
try register(messaging: messaging, on: app)
// The built-in identity provider, only when a signing key is configured.
//
// 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.
if let seed = config.issuerSigningKeyRaw {
guard (try? Curve25519.Signing.PrivateKey(rawRepresentation: seed)) != nil else {
app.logger.critical("FN_ISSUER_PRIVATE_KEY is not a 32-byte Ed25519 seed")
throw Config.ConfigError.badIssuerKey
}
let accounts = AccountRoutes(
issuer: Issuer(
signingKeyRaw: seed,
audience: config.audience,
lifetime: config.tokenLifetime),
limits: RateLimiter(window: AccountRoutes.signinLockout))
try register(account: accounts, on: app)
// The shipped iOS client's surface, mounted where it already looks.
// It authenticates with the same app credential, so it needs the
// identity provider — without one there is nothing to authenticate
// against, and the routes would refuse every call.
let mobile = MobileRoutes(
accounts: accounts,
atRestKey: AtRestKey(secret: config.atRestSecret),
limits: RateLimiter())
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
// 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.
let compat = app.grouped("api", "flamenet", "v1", "messenger")
compat.post("signup") { req in try await accounts.signup(req) }
compat.post("signin") { req in
try await accounts.signin(req, clientIP: clientIP(req), now: unixNow())
}
// 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 {
compat.post("session") { req in
try await accounts.enclaveSession(req, secret: enclaveSecret, now: unixNow())
}
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()) }
// The namespace index, so a client can find out what this relay serves
// instead of assuming.
//
// This is what stops the app offering buddy commands here. It probes
// for this document and looks for the command routes in it; a relay
// that does not serve them is not listing them, so the command menu
// switches itself off. Without the index the probe 404s, the app keeps
// 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
}
}
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
// described intent rather than behaviour.
let retention = Retention(
db: app.db, config: config, logger: app.logger,
attachmentsDirectory: routes.attachmentsDirectory(app))
let sweeper = Task { await retention.run() }
app.lifecycle.use(StopTask(task: sweeper))
app.logger.info("relay listening on \(config.host):\(config.port) for \(config.audience)")
}
func register(routes r: Routes, on app: Application) throws {
app.get("health") { _ in "ok" }
// The landing page. Served by the relay itself so that a self-hoster gets
// something at the root explaining what they are running, rather than a 404
// that reads like a broken deploy.
app.get { req async throws -> View in
try await req.view.render("landing", LandingContext(
title: "Flamenet Messenger",
description: "A private messenger. Messages are locked on your phone and can "
+ "only be opened by the person you sent them to \u{2014} no phone number, "
+ "no ads, and a server you can run yourself.",
audience: r.config.audience,
protoVersion: 3))
}
// The rest of the site. These are ordinary pages rather than links off to
// somewhere else, because a project that asks to be taken on its own terms
// should not send people elsewhere to read its terms.
let pages: [(String, String, String)] = [
("comparison", "How this compares — Flamenet Messenger",
"Flamenet Messenger next to Signal, Telegram and Apple Messages, "
+ "including the rows where it comes off worse."),
("about", "About — Flamenet Messenger",
"What Flamenet Messenger is, why it exists, and what it does not claim to be."),
("community", "Community — Flamenet Messenger",
"How to review the protocol, report a bug, report a vulnerability, or run a relay."),
("security", "Security — Flamenet Messenger",
"How to report a vulnerability, and what this project does and does not defend against."),
("spec", "Specification — Flamenet Messenger",
"One document covering primitives, the ratchet, and the server's REST contract."),
("roadmap", "Roadmap — Flamenet Messenger",
"What is not done yet, in roughly the order it matters."),
("terms", "Terms of Use — Flamenet Messenger",
"Terms covering the relay operated at this domain."),
("metadata", "What the relay keeps — Flamenet Messenger",
"Exactly which metadata a relay stores, for how long, and what was deliberately removed."),
("privacy", "Privacy Policy — Flamenet Messenger",
"What the relay stores, what it can see, and for how long."),
("licenses", "Licences — Flamenet Messenger",
"MIT licence text and the third-party components this project depends on."),
("source", "Source code — Flamenet Messenger",
"Where the engine and relay live, and what is in the repository."),
// Generated from the repository's markdown by Scripts/build-doc-pages.sh.
("audit", "Audit scope — Flamenet Messenger",
"A brief for an external reviewer: scope, trust boundaries, assets, and the claims we would like attacked."),
("self-hosting", "Self-hosting — Flamenet Messenger",
"How to run your own relay, and how to verify what it is serving."),
("engine", "Engine — Flamenet Messenger",
"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."),
]
// 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)
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,
processorName: (r.config.donateProvider ?? "the processor").capitalized))
}
app.post("donate", "checkout") { req in try await donate.checkout(req) }
for (slug, title, description) in pages {
app.get(.constant(slug)) { req async throws -> View in
try await req.view.render(slug, LandingContext(
title: title,
description: description,
audience: r.config.audience,
protoVersion: 3))
}
}
// 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
// RFC 9116 requires Expires, and a stale one is worse than none: it
// tells a researcher the file is abandoned. Computed a year out from
// the running process so a long-lived deploy does not go stale.
let expires = ISO8601DateFormatter().string(
from: Date().addingTimeInterval(365 * 24 * 60 * 60))
let body = """
Contact: mailto:security@flamenetmessenger.com
Expires: \(expires)
Preferred-Languages: en
Canonical: https://flamenetmessenger.com/.well-known/security.txt
"""
let response = Response(status: .ok, body: .init(string: body))
response.headers.replaceOrAdd(name: .contentType, value: "text/plain; charset=utf-8")
return response
}
// §8 is specified at `/e2e/*`, and that is the canonical mount.
//
// It is also mounted under the WordPress REST 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.
//
// Safe because the proof signature commits to the logical §8 path that the
// handler passes to `authenticate`, not to the URL the request arrived on.
// A proof is therefore identical at either mount and, being single-use,
// still cannot be replayed from one to the other.
func mountE2E(_ e2e: any RoutesBuilder) {
e2e.post("devices") { req in
try await r.registerDevice(req, body: body(of: req), now: unixNow())
}
e2e.get("keys", ":user_id") { req in
guard let target = req.parameters.get("user_id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await r.bundles(req, target: target, now: unixNow())
}
// Digits are a user id, anything else is a device id, and a device id is a
// UUID — the two patterns cannot overlap, which is what lets them share a
// path segment. The GET is guarded by the Int cast; the DELETE is a
// different method entirely.
e2e.get("devices", ":user_id") { req in
guard let target = req.parameters.get("user_id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await r.listDevices(req, target: target, now: unixNow())
}
e2e.delete("devices", ":device_id") { req in
guard let id = req.parameters.get("device_id") else {
throw WireError.badRequest("Malformed device id.")
}
return try await r.revokeDevice(req, deviceID: id, now: unixNow())
}
e2e.post("messages") { req in
try await r.send(req, body: body(of: req), clientIP: clientIP(req), now: unixNow())
}
e2e.get("messages") { req in
try await r.poll(req, now: unixNow())
}
e2e.post("prekeys") { req in
try await r.replenish(req, body: body(of: req), now: unixNow())
}
// §14.4
e2e.post("delivery-key") { req in
try await r.publishDeliveryKey(req, body: body(of: req), now: unixNow())
}
// §8.7 — the body is a raw ciphertext blob, not JSON, and needs its own
// collection limit: the global default is deliberately small so that an
// unauthenticated sealed submission cannot ask for much memory.
e2e.on(.POST, "attachments",
body: .collect(maxSize: ByteCount(value: r.config.attachmentMaxBytes))) { req in
try await r.uploadAttachment(req, body: body(of: req), now: unixNow())
}
e2e.get("attachments", ":token") { req in
guard let token = req.parameters.get("token") else {
throw WireError.badRequest("Malformed attachment id.")
}
return try await r.downloadAttachment(req, token: token, now: unixNow())
}
// §8.9
e2e.post("groups") { req in
try await r.createGroup(req, body: body(of: req), now: unixNow())
}
e2e.get("groups") { req in
try await r.listGroups(req, now: unixNow())
}
e2e.post("groups", ":gid", "members") { req in
guard let gid = req.parameters.get("gid"), isValidAttachmentToken(gid) else {
throw WireError.badRequest("Malformed group id.")
}
return try await r.updateGroupMembers(req, gid: gid, body: body(of: req), now: unixNow())
}
e2e.post("groups", ":gid", "leave") { req in
guard let gid = req.parameters.get("gid"), isValidAttachmentToken(gid) else {
throw WireError.badRequest("Malformed group id.")
}
return try await r.leaveGroup(req, gid: gid, body: body(of: req), now: unixNow())
}
// §19 — the transparency log. Unauthenticated: a log only its own
// users can read is not a transparency log.
e2e.get("log", "head") { req in try await r.logHead(req) }
e2e.get("log", "inclusion") { req in try await r.logInclusion(req) }
e2e.get("log", "consistency") { req in try await r.logConsistency(req) }
e2e.get("log", "device", ":device_id") { req in
guard let id = req.parameters.get("device_id") else {
throw WireError.badRequest("Malformed device id.")
}
return try await r.logDevice(req, deviceID: id)
}
// §8.10
e2e.get("turn") { req in
try await r.turnCredentials(req, now: unixNow())
}
}
mountE2E(app.grouped("e2e"))
mountE2E(app.grouped("api", "flamenet", "v1", "e2e"))
}
func register(messaging m: MessagingRoutes, on app: Application) throws {
let g = app.grouped("messenger")
g.get("buddies") { req in try await m.listBuddies(req, now: unixNow()) }
g.post("buddies") { req in try await m.addBuddy(req, body: body(of: req), now: unixNow()) }
g.delete("buddies", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed buddy id.")
}
return try await m.removeBuddy(req, buddyID: id, now: unixNow())
}
g.post("presence") { req in try await m.setPresence(req, body: body(of: req), now: unixNow()) }
g.get("presence", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.getPresence(req, userID: id, now: unixNow())
}
g.post("messages") { req in try await m.send(req, body: body(of: req), now: unixNow()) }
g.get("thread", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.thread(req, with: id, now: unixNow())
}
g.post("read") { req in try await m.markRead(req, body: body(of: req), now: unixNow()) }
g.get("unread") { req in try await m.unread(req, now: unixNow()) }
g.post("conversation", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.setConversation(req, other: id, body: body(of: req), now: unixNow())
}
}
/// 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.
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) }
g.post("buddies") { req in try await m.addBuddy(req) }
g.delete("buddies", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed buddy id.")
}
return try await m.removeBuddy(req, buddyID: id)
}
g.post("messages") { req in try await m.send(req, now: unixNow()) }
g.get("messages") { req in try await m.poll(req) }
g.post("status") { req in try await m.status(req) }
g.post("conversation") { req in try await m.conversation(req) }
g.get("tier") { req in try await m.tier(req) }
g.get("services") { req in try await m.services(req) }
g.post("services", ":network") { req in
try await m.connectService(req, network: req.parameters.get("network") ?? "")
}
g.delete("services", ":network") { req in
try await m.disconnectService(req, network: req.parameters.get("network") ?? "")
}
g.get("notifications") { req in try await m.notifications(req) }
g.post("notifications", ":id", "read") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed notification id.")
}
return try await m.markNotificationRead(req, id: id)
}
g.post("notifications", "read_all") { req in try await m.markAllNotificationsRead(req) }
g.get("blocks") { req in try await m.blocks(req) }
g.post("blocks", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.toggleBlock(req, target: id, blocking: true)
}
g.delete("blocks", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.toggleBlock(req, target: id, blocking: false)
}
g.post("command") { req in try await m.command(req) }
}
func register(account a: AccountRoutes, 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()) }
}
func unixNow() -> Int { Int(Date().timeIntervalSince1970) }
/// The raw request bytes.
///
/// Raw, not re-encoded from a decoded model: the proof-of-possession signature
/// commits to a digest of exactly these bytes, and hashing a re-serialisation
/// would reject every honest request whose JSON does not round-trip identically.
func body(of request: Request) -> Data {
guard let buffer = request.body.data else { return Data() }
return Data(buffer.readableBytesView)
}
/// The address to meter sealed submissions by.
///
/// `X-Forwarded-For` is trusted because this is expected to run behind a
/// reverse proxy that sets it. Exposed directly to the internet the header is
/// attacker-controlled and the per-IP half of sealed metering becomes
/// decorative — so deploy it behind a proxy, or that limit is theatre.
func clientIP(_ request: Request) -> String {
if let forwarded = request.headers.first(name: .xForwardedFor) {
return forwarded.split(separator: ",").first
.map { $0.trimmingCharacters(in: .whitespaces) } ?? "unknown"
}
return request.remoteAddress?.ipAddress ?? "unknown"
}
/// Renders `WireError` as the JSON body clients already parse.
struct WireErrorMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
do {
return try await next.respond(to: request)
} catch let e as WireError {
return try .json(e, status: e.httpStatus)
} catch let e as any DatabaseError {
// Logged, never returned: a database error string can carry table
// and column names, and this is the one component whose schema
// shape should not be free to enumerate over HTTP.
request.logger.error("storage failure: \(e)")
return try .json(WireError("server_error", "Storage failure.", 500),
status: .internalServerError)
}
}
}
/// Cancels a background task when the application shuts down.
struct StopTask: LifecycleHandler {
let task: Task<Void, Never>
func shutdown(_ application: Application) { task.cancel() }
}
/// What the landing template is allowed to know. Deliberately not the config
/// object: a template that can reach the issuer key is a template that can
/// print it.
/// A short fingerprint of the stylesheet and script, for cache busting.
///
/// Without it the browser keeps serving whichever CSS it fetched first, and a
/// deployed change appears not to have happened — which is indistinguishable
/// from a broken deploy right up until someone hard-reloads. Computed once at
/// 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"]
var hasher = SHA256()
for path in paths {
if let data = FileManager.default.contents(atPath: path) { hasher.update(data: data) }
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined().prefix(8).description
}()
}
struct DonateContext: Encodable {
let title: String
let description: String
let audience: String
let protoVersion: Int
let donateEnabled: Bool
let processorName: String
let assetVersion: String = AssetVersion.value
}
struct LandingContext: Encodable {
let title: String
let description: String
let audience: String
let protoVersion: Int
let assetVersion: String = AssetVersion.value
}
/// Response headers for the landing page.
///
/// The relay's own origin runs no third-party script — that is the point of
/// moving off the portal's origin, and it would be undone quietly by an
/// analytics tag added to this page later. `default-src 'self'` makes that a
/// broken page rather than a silent regression. The API responses are JSON and
/// carry it harmlessly.
struct SecurityHeadersMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let response = try await next.respond(to: request)
response.headers.replaceOrAdd(
name: .contentSecurityPolicy,
value: "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
response.headers.replaceOrAdd(name: .init("X-Content-Type-Options"), value: "nosniff")
response.headers.replaceOrAdd(name: .init("Referrer-Policy"), value: "no-referrer")
response.headers.replaceOrAdd(
name: .init("Strict-Transport-Security"), value: "max-age=31536000; includeSubDomains")
return response
}
}
+109
View File
@@ -0,0 +1,109 @@
import Crypto
import Foundation
import Vapor
/// Mints the tokens the relay verifies.
///
/// ## Why this can be in the same binary, and what it costs
///
/// The architecture note is emphatic that the relay must not call the issuer on
/// the request path, and it does not: the relay checks a pinned public key and
/// never reads a row from `users`. Co-located is not the same as coupled, and
/// the code path is the thing that matters, not the process boundary.
///
/// 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
/// 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
/// 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
/// the same Linux `Sendable` reason as `Config.issuerKeyRaw`.
let signingKeyRaw: Data
let audience: String
let lifetime: Int
/// How long a minted token lives.
///
/// Short, because there is no revocation list: the only thing that stops a
/// leaked token is its own expiry. Five minutes is enough for a burst of
/// requests and short enough that a token scraped from a log is usually
/// already dead. Clients re-mint from their stored credential; that
/// exchange is cheap and does not involve the user.
static let defaultLifetime = 300
func mint(userID: Int, deviceID: String, identityKey: Data, now: Int) throws -> String {
guard let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingKeyRaw) else {
throw Abort(.internalServerError, reason: "issuer key is unusable")
}
let header = TokenHeader(alg: "EdDSA", typ: "FNRT")
let claims = TokenClaims(
iss: "flamenet-relay",
sub: userID,
dev: deviceID,
aud: audience,
iat: now,
exp: now + lifetime,
cnf: .init(ik_sig: identityKey.base64EncodedString())
)
// Encoded once and signed over exactly those bytes. Re-serialising the
// claims to verify would be a different byte string if key order ever
// changed, and the relay verifies the segments it received.
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let h = base64URLEncode(try encoder.encode(header))
let c = base64URLEncode(try encoder.encode(claims))
let signature = try key.signature(for: Data("\(h).\(c)".utf8))
return "\(h).\(c).\(base64URLEncode(signature))"
}
/// The public half, for handing to a relay that will verify these.
var publicKeyBase64: String? {
guard let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingKeyRaw) else {
return nil
}
return key.publicKey.rawRepresentation.base64EncodedString()
}
}
// MARK: - Wire
struct SignupRequest: Content {
let username: String
let email: String
let password: String
let display_name: String?
}
struct SigninRequest: Content {
let username: String
let password: String
}
struct AccountResponse: Content {
let user_id: Int
let username: String
let display_name: String
/// The long-lived credential. Returned once, at the moment it is minted,
/// and never retrievable again — only its hash is kept.
let app_password: String
}
struct TokenRequest: Content {
let device_id: String
/// The device's Ed25519 identity key, base64. The token is bound to it.
let ik_sig: String
}
struct TokenResponse: Content {
let token: String
let expires_in: Int
let audience: String
}
+87
View File
@@ -0,0 +1,87 @@
import Crypto
import Fluent
import Foundation
import SQLKit
import Vapor
/// One entry in the key transparency log (§19).
///
/// The log records every device key this relay has ever served, in order. A
/// client that is handed a prekey bundle can demand a proof that the identity
/// key in it is the key at some position in this log, and that the log it is
/// being shown today extends the one it was shown yesterday.
final class LogEntry: Model, @unchecked Sendable {
static let schema = "key_log"
/// Position in the log. Assigned by the database, so it is dense and
/// ordered, which the Merkle tree requires.
@ID(custom: "seq", generatedBy: .database) var id: Int?
@Field(key: "leaf_hash") var leafHash: String
@Field(key: "user_id") var userID: Int
@Field(key: "device_id") var deviceID: String
/// "publish" or "revoke".
@Field(key: "kind") var kind: String
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
struct CreateLogEntry: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(LogEntry.schema)
.field("seq", .int, .identifier(auto: true))
.field("leaf_hash", .string, .required)
.field("user_id", .int, .required)
.field("device_id", .string, .required)
.field("kind", .string, .required)
.field("created_at", .datetime)
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS key_log_device ON key_log(device_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(LogEntry.schema).delete()
}
}
enum KeyLog {
/// The canonical bytes a leaf commits to.
///
/// Hand-built rather than `JSONEncoder`, because a leaf hash has to be
/// 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,
ikDH: String, kind: String) -> Data {
Data("v1|\(kind)|\(userID)|\(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
) async throws {
let leaf = MerkleLog.leafHash(
canonical(userID: userID, deviceID: deviceID, ikSig: ikSig, ikDH: ikDH, kind: kind))
let row = LogEntry()
row.leafHash = leaf.base64EncodedString()
row.userID = userID
row.deviceID = deviceID
row.kind = kind
try await row.create(on: db)
}
/// Every leaf hash, in log order.
///
/// Recomputed per request rather than cached. That is honest for the size
/// this log is at and wrong for the size it will reach: the fix is a stored
/// tree, and it is a performance change rather than a protocol one, so it
/// can wait until the numbers say so.
static func leaves(on db: any Database) async throws -> [Data] {
try await LogEntry.query(on: db).sort(\.$id, .ascending).all()
.compactMap { Data(base64Encoded: $0.leafHash) }
}
}
+124
View File
@@ -0,0 +1,124 @@
import Crypto
import Foundation
/// An append-only log of key publications, in the RFC 6962 shape.
///
/// The problem it exists for: the relay hands out prekey bundles, so a
/// dishonest one can hand out a key it controls. Safety numbers detect that,
/// but they require both people to compare a string by hand, which
/// approximately nobody does. A transparency log makes substitution leave
/// evidence instead: every device key the relay serves must first be appended
/// here, and a client can demand a proof that the key it was given is the key
/// in the log.
///
/// What this buys, precisely: the relay cannot show one key to a victim and a
/// different key to everyone else *without producing two logs*. It cannot
/// quietly rewrite history, because a consistency proof between any two heads
/// it has signed would fail. What it does **not** buy on its own is detection
/// of a relay that forks the log and shows each victim a self-consistent
/// branch — that needs the signed heads to be gossiped between clients, which
/// is §19.4 and is not implemented here. This is the substrate that makes that
/// possible, not the whole answer.
///
/// Domain separation follows RFC 6962: leaves are hashed under 0x00 and
/// interior nodes under 0x01, so a leaf can never be reinterpreted as a node.
enum MerkleLog {
static func leafHash(_ data: Data) -> Data {
var input = Data([0x00])
input.append(data)
return Data(SHA256.hash(data: input))
}
static func nodeHash(_ left: Data, _ right: Data) -> Data {
var input = Data([0x01])
input.append(left)
input.append(right)
return Data(SHA256.hash(data: input))
}
/// Merkle Tree Hash over `leaves`, which are already leaf-hashed.
static func root(_ leaves: [Data]) -> Data {
guard !leaves.isEmpty else { return Data(SHA256.hash(data: Data())) }
if leaves.count == 1 { return leaves[0] }
let k = split(leaves.count)
return nodeHash(root(Array(leaves[..<k])), root(Array(leaves[k...])))
}
/// The largest power of two strictly less than `n` — RFC 6962's split point.
/// Not `n/2`: an unbalanced split is what makes the tree's shape depend only
/// on its size, so two verifiers agree without exchanging structure.
static func split(_ n: Int) -> Int {
var k = 1
while k << 1 < n { k <<= 1 }
return k
}
/// Audit path proving `index` is in a tree of `leaves.count`.
static func inclusionProof(_ leaves: [Data], index: Int) -> [Data]? {
guard index >= 0, index < leaves.count else { return nil }
if leaves.count == 1 { return [] }
let k = split(leaves.count)
if index < k {
guard let sub = inclusionProof(Array(leaves[..<k]), index: index) else { return nil }
return sub + [root(Array(leaves[k...]))]
} else {
guard let sub = inclusionProof(Array(leaves[k...]), index: index - k) else { return nil }
return sub + [root(Array(leaves[..<k]))]
}
}
/// Recompute a root from a leaf and its audit path. This is the operation a
/// client performs; it is here so the server's tests exercise the same code
/// path a verifier would.
static func rootFromProof(leaf: Data, index: Int, size: Int, proof: [Data]) -> Data? {
guard index >= 0, index < size else { return nil }
var hash = leaf
var idx = index
var n = size
var path = proof[...]
while n > 1 {
guard let sibling = path.first else { return nil }
path = path.dropFirst()
let k = split(n)
if idx < k {
hash = nodeHash(hash, sibling)
n = k
} else {
hash = nodeHash(sibling, hash)
idx -= k
n -= k
}
}
return path.isEmpty ? hash : nil
}
/// Proof that a tree of size `m` is a prefix of a tree of size `n`.
///
/// This is the half that makes the log *append-only* rather than merely
/// hashed: without it a relay could rewrite an old entry and publish a new
/// root, and every fresh inclusion proof would still verify.
static func consistencyProof(_ leaves: [Data], from m: Int, to n: Int) -> [Data]? {
guard m > 0, m <= n, n <= leaves.count else { return nil }
if m == n { return [] }
return subProof(leaves, m: m, n: n, isCompleteSubtree: true)
}
private static func subProof(
_ leaves: [Data], m: Int, n: Int, isCompleteSubtree: Bool
) -> [Data]? {
if m == n {
return isCompleteSubtree ? [] : [root(Array(leaves[..<n]))]
}
let k = split(n)
if m <= k {
guard let sub = subProof(Array(leaves[..<k]), m: m, n: k,
isCompleteSubtree: isCompleteSubtree) else { return nil }
return sub + [root(Array(leaves[k..<n]))]
} else {
guard let sub = subProof(Array(leaves[k..<n]), m: m - k, n: n - k,
isCompleteSubtree: false) else { return nil }
return sub + [root(Array(leaves[..<k]))]
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import Fluent
import Foundation
import SQLKit
import Vapor
/// A one-way buddy-list entry. Mutual visibility is two rows, as in the plugin
/// — following is not symmetric and never was.
final class Buddy: Model, @unchecked Sendable {
static let schema = "buddies"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
@Field(key: "buddy_id") var buddyID: Int
init() {}
}
/// A stored message body — the **non-E2E** path.
///
/// Read this before adding to it. An end-to-end thread writes no row here at
/// all; its ciphertext goes through the envelope queue and the server holds no
/// key. This table is the other case: a conversation with encryption turned
/// off, stored so that search and previews can work.
///
/// The body is sealed at rest, and that seal is emphatically **not** protection
/// from the operator — we hold the key. It defends against a stolen database
/// dump alone, because the key lives in configuration rather than in the table.
/// It does not defend against a subpoena, an operator, or anyone who has both.
///
/// This is the surface that gets subpoenaed and the one the marketing copy has
/// to stay honest about. Anything claiming there is "no decryptable archive"
/// is false while this table has rows in it.
final class Message: Model, @unchecked Sendable {
static let schema = "messages"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "from_id") var fromID: Int
@Field(key: "to_id") var toID: Int
/// Sealed by `AtRestCrypto`. Never read this without going through it.
@Field(key: "body") var body: String
@Field(key: "is_read") var isRead: Bool
/// When this body should cease to exist, if the conversation sets a
/// lifetime. Enforced by the retention sweep, not by the client hiding it.
@OptionalField(key: "expires_at") var expiresAt: Date?
@Timestamp(key: "sent_at", on: .create) var sentAt: Date?
init() {}
}
struct CreateBuddy: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Buddy.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("buddy_id", .int, .required)
.field("added_at", .datetime)
.unique(on: "user_id", "buddy_id")
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS buddies_user ON buddies(user_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Buddy.schema).delete()
}
}
struct CreateMessage: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Message.schema)
.field("id", .int, .identifier(auto: true))
.field("from_id", .int, .required)
.field("to_id", .int, .required)
.field("body", .string, .required)
.field("is_read", .bool, .required, .sql(.default(false)))
.field("expires_at", .datetime)
.field("sent_at", .datetime)
.create()
let sql = database as? any SQLDatabase
// A thread is read by (pair, id) and unread counts by (recipient,
// is_read); both are the queries the inbox makes on every poll.
try await sql?.raw("CREATE INDEX IF NOT EXISTS messages_pair ON messages(from_id, to_id, id)").run()
try await sql?.raw("CREATE INDEX IF NOT EXISTS messages_unread ON messages(to_id, is_read)").run()
try await sql?.raw("CREATE INDEX IF NOT EXISTS messages_expiry ON messages(expires_at)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Message.schema).delete()
}
}
/// What a user picked. `invisible` reads as offline to everyone else.
enum ChosenStatus: String, Codable, CaseIterable {
case online, away, invisible
}
/// What a viewer sees. There is no `invisible` here on purpose: the whole point
/// is that the choice is not observable.
enum EffectiveStatus: String, Codable {
case online, away, offline
}
+234
View File
@@ -0,0 +1,234 @@
import Fluent
import SQLKit
/// Schema, mirroring spec §9. Every payload column is opaque: the relay stores
/// base64 exactly as received and never decodes it, which is why no column type
/// here says anything about message content.
struct CreateDevice: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Device.schema)
.field("device_id", .string, .identifier(auto: false))
.field("user_id", .int, .required)
.field("ik_dh", .string, .required)
.field("ik_sig", .string, .required)
.field("spk_id", .int, .required)
.field("spk", .string, .required)
.field("spk_sig", .string, .required)
.field("proto", .int, .required, .sql(.default(1)))
.field("caps_sig", .string)
.field("pqspk_id", .int, .required, .sql(.default(0)))
.field("pqspk", .string)
.field("pqspk_sig", .string)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.create()
try await (database as? any SQLDatabase)?.raw("CREATE INDEX IF NOT EXISTS devices_user ON devices(user_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Device.schema).delete()
}
}
struct CreatePreKey: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(PreKey.schema)
.field("id", .int, .identifier(auto: true))
.field("device_id", .string, .required)
.field("prekey_id", .int, .required)
.field("pub", .string, .required)
.field("used", .bool, .required, .sql(.default(false)))
.field("created_at", .datetime)
.unique(on: "device_id", "prekey_id")
.create()
// The pool query is (device_id, used, id) — ordered, because the pop
// takes the lowest unused id and an unindexed scan there is the one
// read that grows with the size of the pool.
try await (database as? any SQLDatabase)?.raw("CREATE INDEX IF NOT EXISTS prekeys_pool ON prekeys(device_id, used, id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(PreKey.schema).delete()
}
}
struct CreatePQPreKey: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(PQPreKey.schema)
.field("id", .int, .identifier(auto: true))
.field("device_id", .string, .required)
.field("prekey_id", .int, .required)
.field("pub", .string, .required)
.field("sig", .string, .required)
.field("used", .bool, .required, .sql(.default(false)))
.field("created_at", .datetime)
.unique(on: "device_id", "prekey_id")
.create()
try await (database as? any SQLDatabase)?.raw("CREATE INDEX IF NOT EXISTS pq_prekeys_pool ON pq_prekeys(device_id, used, id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(PQPreKey.schema).delete()
}
}
struct CreateEnvelope: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Envelope.schema)
.field("id", .int, .identifier(auto: true))
.field("to_user", .int, .required)
.field("to_device", .string, .required)
.field("from_user", .int, .required, .sql(.default(0)))
.field("from_device", .string, .required, .sql(.default("")))
.field("payload", .string, .required)
.field("delivered", .bool, .required, .sql(.default(false)))
.field("created_at", .datetime)
.create()
let sql = database as? any SQLDatabase
try await sql?.raw("CREATE INDEX IF NOT EXISTS envelopes_inbox ON envelopes(to_device, id)").run()
try await sql?.raw("CREATE INDEX IF NOT EXISTS envelopes_retention ON envelopes(delivered, created_at)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Envelope.schema).delete()
}
}
struct CreateGroup: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Group.schema)
.field("gid", .string, .identifier(auto: false))
.field("name", .string, .required)
.field("owner_user_id", .int, .required)
.field("created_at", .datetime)
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(Group.schema).delete()
}
}
struct CreateGroupMember: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(GroupMember.schema)
.field("id", .int, .identifier(auto: true))
.field("gid", .string, .required)
.field("user_id", .int, .required)
.field("created_at", .datetime)
.unique(on: "gid", "user_id")
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS group_members_user ON group_members(user_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(GroupMember.schema).delete()
}
}
struct CreateAttachment: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Attachment.schema)
.field("token", .string, .identifier(auto: false))
.field("user_id", .int, .required)
.field("bytes", .int, .required)
.field("created_at", .datetime)
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS attachments_retention ON attachments(created_at)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Attachment.schema).delete()
}
}
struct CreateDeliveryKey: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(DeliveryKey.schema)
.field("user_id", .int, .identifier(auto: false))
.field("digest", .string, .required)
.field("updated_at", .datetime)
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(DeliveryKey.schema).delete()
}
}
/// Drop columns that were written and never read.
///
/// Each was a durable record of something the relay does not need to answer any
/// request: who uploaded a blob, when a buddy was added, when a block was set,
/// when someone joined a group. Retention sweeps by age using columns that
/// remain; nothing else consulted these.
///
/// The attachment one is the load-bearing removal. A sealed envelope stores
/// `from_user = 0`, and then the attachment it referenced named the uploader
/// for the life of the blob — so sealing hid the sender everywhere except the
/// one row that gave them away.
struct TrimUnreadMetadata: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Attachment.schema).deleteField("user_id").update()
try await database.schema(Buddy.schema).deleteField("added_at").update()
try await database.schema(Block.schema).deleteField("created_at").update()
try await database.schema(GroupMember.schema).deleteField("created_at").update()
}
func revert(on database: any Database) async throws {
try await database.schema(Attachment.schema).field("user_id", .int).update()
try await database.schema(Buddy.schema).field("added_at", .datetime).update()
try await database.schema(Block.schema).field("created_at", .datetime).update()
try await database.schema(GroupMember.schema).field("created_at", .datetime).update()
}
}
/// §20 — post-quantum identity keys.
struct AddPQIdentity: AsyncMigration {
func prepare(on database: any Database) async throws {
// Two statements, not one `.update()` with two fields: SQLite permits a
// single ADD COLUMN per ALTER and rejects the combined form outright.
try await database.schema(Device.schema).field("ik_pq", .string).update()
try await database.schema(Device.schema).field("spk_pq_sig", .string).update()
}
func revert(on database: any Database) async throws {
try await database.schema(Device.schema).deleteField("ik_pq").update()
try await database.schema(Device.schema).deleteField("spk_pq_sig").update()
}
}
/// Link a relay account to a shared FlameNet account.
///
/// Nullable, because a self-hosted relay has no account service and its users
/// are local ones. Unique, because two relay accounts claiming the same
/// FlameNet pseudonym would mean one person silently signing in as either.
struct AddEnclavePseudonym: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(User.schema).field("enclave_pseudonym", .string).update()
// Enforced in the database rather than only in the handler: the check
// and the insert are not one operation, and two sign-ins arriving
// together is exactly when that gap matters.
try await (database as? any SQLDatabase)?.raw("""
CREATE UNIQUE INDEX IF NOT EXISTS users_enclave_pseudonym
ON users (enclave_pseudonym)
""").run()
}
func revert(on database: any Database) async throws {
try await (database as? any SQLDatabase)?.raw("DROP INDEX IF EXISTS users_enclave_pseudonym").run()
try await database.schema(User.schema).deleteField("enclave_pseudonym").update()
}
}
/// Let an account exist without an email address.
///
/// One authenticated by the shared FlameNet account service has none here, and
/// the column is unique — so a placeholder is not available either: the first
/// such account takes it and the next collides. Which is exactly how this was
/// found, on the second account.
struct AllowNullEmail: AsyncMigration {
func prepare(on database: any Database) async throws {
try await (database as? any SQLDatabase)?
.raw("ALTER TABLE users ALTER COLUMN email DROP NOT NULL").run()
}
func revert(on database: any Database) async throws {
// Deliberately not reinstating NOT NULL: rows created since would have
// to be given an address they never had, and inventing one is worse
// than leaving the column nullable.
}
}
+174
View File
@@ -0,0 +1,174 @@
import Fluent
import Foundation
import Vapor
/// One registered device: an identity, a signed prekey, and optionally a
/// post-quantum block.
///
/// Keyed by the client-generated `device_id`, and a device id belongs to exactly
/// one account for as long as it exists. Everything stored here is public key
/// material the client chose to publish.
final class Device: Model, @unchecked Sendable {
static let schema = "devices"
@ID(custom: "device_id", generatedBy: .user) var id: String?
@Field(key: "user_id") var userID: Int
@Field(key: "ik_dh") var ikDH: String
@Field(key: "ik_sig") var ikSig: String
@Field(key: "spk_id") var spkID: Int
@Field(key: "spk") var spk: String
@Field(key: "spk_sig") var spkSig: String
@Field(key: "proto") var proto: Int
@OptionalField(key: "caps_sig") var capsSig: String?
@Field(key: "pqspk_id") var pqSpkID: Int
@OptionalField(key: "pqspk") var pqSpk: String?
@OptionalField(key: "pqspk_sig") var pqSpkSig: String?
/// §20 post-quantum identity. Optional because publishing one is a device
/// capability, not a protocol requirement: a device without it is verified
/// classically, exactly as before.
@OptionalField(key: "ik_pq") var ikPQ: String?
@OptionalField(key: "spk_pq_sig") var spkPQSig: String?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
init() {}
/// True when the device published a complete v2 block.
///
/// All-or-nothing on purpose: a half-populated bundle reads as a downgrade
/// to a peer pinned at `minProto = 2`, so serving nothing v2-shaped is
/// better than serving something partial.
var hasCompleteV2Block: Bool {
proto >= 2 && capsSig != nil && pqSpk != nil && pqSpkSig != nil
}
}
/// A classical one-time prekey. `used` is flipped rather than the row deleted,
/// so a racing fetch cannot resurrect a consumed key by beating the delete.
final class PreKey: Model, @unchecked Sendable {
static let schema = "prekeys"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "device_id") var deviceID: String
@Field(key: "prekey_id") var preKeyID: Int
@Field(key: "pub") var pub: String
@Field(key: "used") var used: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
/// A one-time ML-KEM-768 prekey. Carries its own signature, because a v2 peer
/// verifies each one-time key individually (spec §12.3).
final class PQPreKey: Model, @unchecked Sendable {
static let schema = "pq_prekeys"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "device_id") var deviceID: String
@Field(key: "prekey_id") var preKeyID: Int
@Field(key: "pub") var pub: String
@Field(key: "sig") var sig: String
@Field(key: "used") var used: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
/// One sealed envelope, addressed to one device.
///
/// `fromUser` is 0 and `fromDevice` is "" for a sealed submission — stored
/// empty, never null and never taken from a credential, so that every sealed
/// row is indistinguishable from every other. The real values travel inside the
/// ciphertext, which this service cannot read.
final class Envelope: Model, @unchecked Sendable {
static let schema = "envelopes"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "to_user") var toUser: Int
@Field(key: "to_device") var toDevice: String
@Field(key: "from_user") var fromUser: Int
@Field(key: "from_device") var fromDevice: String
@Field(key: "payload") var payload: String
@Field(key: "delivered") var delivered: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
/// A group: plaintext metadata only (spec §6.7/§8.9).
///
/// The relay never relays a group message differently from a 1:1 message and
/// cannot tell them apart — senders fan out to the roster themselves. What this
/// table is, honestly, is the roster the relay does get to see, and it remains
/// the largest metadata leak in the design. Porting it does not fix that;
/// Signal's answer is anonymous credentials.
final class Group: Model, @unchecked Sendable {
static let schema = "groups"
@ID(custom: "gid", generatedBy: .user) var id: String?
@Field(key: "name") var name: String
@Field(key: "owner_user_id") var ownerUserID: Int
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
static let maxMembers = 32
static let maxNameLength = 80
}
final class GroupMember: Model, @unchecked Sendable {
static let schema = "group_members"
/// No join timestamp: the roster is already server-visible (§8.9), and
/// recording *when* each member joined turns it into a history of the
/// group's formation on top of that. Nothing read it.
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "gid") var gid: String
@Field(key: "user_id") var userID: Int
init() {}
}
/// The index for a sealed attachment blob. The bytes live on disk; this row is
/// only a token, an owner, and a size.
///
/// Possession of the token IS the download capability — it only ever travels
/// inside the ratchet, which is why it has to be unguessable and why the relay
/// cannot know who the intended recipient is.
final class Attachment: Model, @unchecked Sendable {
static let schema = "attachments"
/// Deliberately no uploader column.
///
/// Nothing read it — retention sweeps by age and a download is authorised
/// by possession of the token — but it was durably recorded for the life of
/// the blob, which handed back exactly what sealed sender removes: the
/// envelope says `from_user = 0` while the attachment it points at named
/// the uploader. Quotas are enforced from the in-memory limiter, which
/// forgets.
@ID(custom: "token", generatedBy: .user) var id: String?
@Field(key: "bytes") var bytes: Int
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
/// A user's sealed-sender delivery-key digest (spec §14.4).
///
/// Only `SHA-256(delivery_key)` is stored. The key itself travels to contacts
/// inside E2E messages, so the relay never sees it except in a sealed
/// submission, which it hashes and compares.
///
/// Keyed by user id in its own table rather than as a column on `users`,
/// because the relay is not necessarily the identity provider: with an external
/// IdP there is no `users` row for the account this digest belongs to.
final class DeliveryKey: Model, @unchecked Sendable {
static let schema = "delivery_keys"
@ID(custom: "user_id", generatedBy: .user) var id: Int?
/// Base64 of the 32-byte SHA-256 digest.
@Field(key: "digest") var digest: String
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
init() {}
}
+107
View File
@@ -0,0 +1,107 @@
import Fluent
import Foundation
import SQLKit
import Vapor
/// Someone this user will not hear from.
///
/// A row, not a JSON blob in a meta field. The plugin kept the list as an
/// encoded array in user meta and had to route every read through one shared
/// helper so the list and the send-path check could not drift apart. A table
/// makes that structural instead of a convention.
final class Block: Model, @unchecked Sendable {
static let schema = "blocks"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
@Field(key: "blocked_id") var blockedID: Int
init() {}
/// The single answer to "may `sender` write to `recipient`".
///
/// Every send path must ask this one function. The plugin's own note says
/// why: a second copy of the check is a second thing to forget.
static func blocks(_ recipient: Int, _ sender: Int, on db: any Database) async throws -> Bool {
try await Block.query(on: db)
.filter(\.$userID == recipient).filter(\.$blockedID == sender).first() != nil
}
}
/// An in-app notification.
///
/// Deliberately thin. The plugin delegated to a separate notifications plugin
/// and returned an empty list when it was absent; this generates only what this
/// server actually knows about — a first contact from a stranger, and being
/// added to someone's buddy list. Anything richer would mean storing a longer
/// record of who did what to whom, which is the opposite of the direction this
/// port has been going.
final class Notification: Model, @unchecked Sendable {
static let schema = "notifications"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
@Field(key: "type") var type: String
@Field(key: "message") var message: String
@Field(key: "url") var url: String
@Field(key: "is_read") var isRead: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
/// Raise one, unless an unread one of the same kind from the same source is
/// already waiting.
///
/// Collapsing matters: without it, a stranger sending forty messages raises
/// forty notifications, which turns the notification list into the
/// harassment channel that blocking them was supposed to close.
static func raise(
_ type: String, for userID: Int, message: String, url: String = "",
on db: any Database
) async throws {
let existing = try await Notification.query(on: db)
.filter(\.$userID == userID).filter(\.$type == type)
.filter(\.$isRead == false).filter(\.$message == message)
.first()
guard existing == nil else { return }
let n = Notification()
n.userID = userID; n.type = type; n.message = message; n.url = url; n.isRead = false
try await n.create(on: db)
}
}
struct CreateBlock: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Block.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("blocked_id", .int, .required)
.field("created_at", .datetime)
.unique(on: "user_id", "blocked_id")
.create()
try await (database as? any SQLDatabase)?
.raw("CREATE INDEX IF NOT EXISTS blocks_pair ON blocks(user_id, blocked_id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Block.schema).delete()
}
}
struct CreateNotification: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Notification.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("type", .string, .required)
.field("message", .string, .required)
.field("url", .string, .required, .sql(.default("")))
.field("is_read", .bool, .required, .sql(.default(false)))
.field("created_at", .datetime)
.create()
try await (database as? any SQLDatabase)?.raw(
"CREATE INDEX IF NOT EXISTS notifications_user ON notifications(user_id, id)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Notification.schema).delete()
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
/// Rolling hourly counters, in memory.
///
/// In memory because the alternative on the PHP side was `get_transient`, which
/// round-trips the options table on every send unless an object cache happens to
/// be installed. The cost of keeping this in process is that counters reset on
/// restart; the cost of not doing so is a database write on the hot path of the
/// one route that has to stay cheap.
actor RateLimiter {
private struct Counter {
var count: Int
var windowStart: Int
}
private var counters: [String: Counter] = [:]
private let window: Int
init(window: Int = 3600) { self.window = window }
/// Consume `amount` against `key`, or refuse. Nothing is consumed on
/// refusal — otherwise hammering one limit would burn the others.
func consume(_ key: String, amount: Int = 1, limit: Int, now: Int) -> Bool {
var c = counters[key] ?? Counter(count: 0, windowStart: now)
if now - c.windowStart >= window { c = Counter(count: 0, windowStart: now) }
guard c.count + amount <= limit else { return false }
c.count += amount
counters[key] = c
return true
}
/// Check every limit before consuming any of them.
func consumeAll(_ requests: [(key: String, amount: Int, limit: Int)], now: Int) -> Bool {
for r in requests {
var c = counters[r.key] ?? Counter(count: 0, windowStart: now)
if now - c.windowStart >= window { c = Counter(count: 0, windowStart: now) }
if c.count + r.amount > r.limit { return false }
}
for r in requests { _ = consume(r.key, amount: r.amount, limit: r.limit, now: now) }
return true
}
/// Read a counter without consuming. Sign-in throttling counts *failures*,
/// so the check and the increment are separate: a successful attempt must
/// not spend an attempt.
func peek(_ key: String, limit: Int, now: Int) -> Bool {
guard let c = counters[key] else { return true }
if now - c.windowStart >= window { return true }
return c.count < limit
}
/// Forget a counter. Called on a successful sign-in, so a person who
/// mistyped four times is not left one slip from a lockout.
func clear(_ key: String) {
counters.removeValue(forKey: key)
}
func sweep(now: Int) {
counters = counters.filter { now - $0.value.windowStart < window }
}
}
+109
View File
@@ -0,0 +1,109 @@
import Fluent
import Foundation
import Logging
/// Deletes what the retention windows say should be gone, on a loop, and says
/// so at a level someone can alert on.
///
/// Envelope rows are ciphertext, but a row is still a record that two accounts
/// exchanged something and when — so the shorter the window the less there is
/// to hand over or leak.
struct Retention {
let db: any Database
let config: Config
let logger: Logger
let attachmentsDirectory: String
static let interval: Duration = .seconds(3600)
func run() async {
while !Task.isCancelled {
do {
let cutoff = { (days: Int) in
Date().addingTimeInterval(-Double(days) * 86_400)
}
let delivered = try await Envelope.query(on: db)
.filter(\.$delivered == true)
.filter(\.$createdAt < cutoff(config.deliveredRetentionDays))
.delete()
let undelivered = try await Envelope.query(on: db)
.filter(\.$delivered == false)
.filter(\.$createdAt < cutoff(config.undeliveredRetentionDays))
.delete()
// Consumed one-time prekeys, once no sender can still be
// holding a bundle that references them.
try await PreKey.query(on: db)
.filter(\.$used == true).filter(\.$createdAt < cutoff(7)).delete()
try await PQPreKey.query(on: db)
.filter(\.$used == true).filter(\.$createdAt < cutoff(7)).delete()
// Blobs are purged regardless of download state: a receiver
// who wanted the image kept a decrypted copy locally, and the
// blob is undownloadable ciphertext after key loss anyway. All
// retention buys is a longer window for slow receivers, at the
// cost of a bigger pile of someone-sent-something-when records.
let expired = try await Attachment.query(on: db)
.filter(\.$createdAt < cutoff(config.attachmentRetentionDays))
.limit(500)
.all()
var blobs = 0
for row in expired {
guard let token = row.id else { continue }
let path = attachmentPath(attachmentsDirectory, token)
// The row goes only if the bytes did. Deleting the index
// first would orphan the blob permanently: nothing would
// ever look at it again, and nothing would ever remove it.
if FileManager.default.fileExists(atPath: path) {
do { try FileManager.default.removeItem(atPath: path) }
catch {
logger.error("attachment unlink failed: \(error)")
continue
}
}
try await row.delete(on: db)
blobs += 1
}
// The non-E2E path. The plugin kept these forever: not just
// the bodies, but a permanent record of who wrote to whom and
// when, which is the more durable disclosure of the two.
//
// Expiry first, because a disappearing message that outlives
// 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.
let lapsed = try await Message.query(on: db)
.filter(\.$expiresAt < Date()).count()
try await Message.query(on: db)
.filter(\.$expiresAt < Date()).delete()
let readCutoff = cutoff(config.readMessageRetentionDays)
let readOld = try await Message.query(on: db)
.filter(\.$isRead == true).filter(\.$sentAt < readCutoff).count()
try await Message.query(on: db)
.filter(\.$isRead == true).filter(\.$sentAt < readCutoff).delete()
let allCutoff = cutoff(config.messageRetentionDays)
let anyOld = try await Message.query(on: db)
.filter(\.$sentAt < allCutoff).count()
try await Message.query(on: db)
.filter(\.$sentAt < allCutoff).delete()
logger.info("retention swept", metadata: [
"delivered": .string("\(delivered)"),
"undelivered": .string("\(undelivered)"),
"attachments": .string("\(blobs)"),
"messages": .string("\(lapsed + readOld + anyOld)"),
])
} catch {
// Logged at error, not swallowed: a purge that silently stops
// is the failure this whole component exists to not repeat.
logger.error("retention sweep failed", metadata: [
"error": .string("\(error)"),
])
}
try? await Task.sleep(for: Self.interval)
}
}
}
+591
View File
@@ -0,0 +1,591 @@
import Crypto
import Fluent
import Foundation
import SQLKit
import Vapor
/// Every `/e2e/*` route, and the auth decision in front of each one.
///
/// Auth is explicit at the top of each handler rather than applied as
/// middleware. Middleware would be tidier and would also make it possible to
/// add a route that quietly inherits the wrong policy — and this service has
/// exactly one route whose policy is deliberately different (see `send`), which
/// is the case a uniform middleware gets wrong.
struct Routes: Sendable {
let config: Config
let verifier: TokenVerifier
let limits: RateLimiter
let replay: ReplayCache
static let maxBodyBytes = 4 * 1024 * 1024
/// Proof-of-possession headers. Named once, so a typo is a compile error in
/// one place rather than a silently unauthenticated request.
enum Headers {
static let timestamp = "X-FN-Timestamp"
static let nonce = "X-FN-Nonce"
static let signature = "X-FN-Signature"
}
// MARK: - Auth
/// Verify the token, AND that its sender possesses the key it was bound to.
func authenticate(
_ request: Request,
body: Data,
path: String,
now: Int
) async throws -> Caller {
guard let token = request.headers.bearerAuthorization?.token else {
throw WireError.unauthorized()
}
do {
let claims = try verifier.verify(token: token, now: now)
let proofSig = request.headers.first(name: Headers.signature)
let caller = try verifier.verifyProof(
claims: claims,
method: request.method.string,
path: path,
body: body,
timestamp: request.headers.first(name: Headers.timestamp),
nonce: request.headers.first(name: Headers.nonce),
signature: proofSig,
now: now
)
// A proof is single-use. Otherwise one captured header pair is
// replayable for the whole skew window, and binding the token to a
// key buys nothing against an attacker who can read one request.
guard let proofSig, await replay.admit(proofSig, now: now) else {
throw AuthError.replayed
}
// The token names a device; that device's registered identity key
// is what the proof must have been made with. Without this, a
// compromised issuer could mint a token whose `cnf` names a key it
// controls and speak as any already-registered device.
if let known = try await Device.find(caller.deviceID, on: request.db) {
guard known.ikSig == caller.identityKey.base64EncodedString() else {
throw AuthError.deviceMismatch
}
}
return caller
} catch let e as AuthError {
throw WireError.unauthorized(e.description)
}
}
// MARK: - §8.1 POST /e2e/devices
func registerDevice(_ request: Request, body: Data, now: Int) async throws -> Response {
let caller = try await authenticate(request, body: body, path: "/e2e/devices", now: now)
let input = try decodeBody(RegisterDeviceRequest.self, from: body)
guard isValidDeviceID(input.device_id) else {
throw WireError.badRequest("Malformed device id.")
}
// The token is scoped to one device, so it cannot write another
// device's keys — which is the entire reason `dev` is a claim.
guard input.device_id == caller.deviceID else {
throw WireError.forbidden("Token is scoped to a different device.")
}
try await request.db.transaction { db in
let existing = try await Device.find(input.device_id, on: db)
// A device id belongs to exactly one account, forever. Device ids
// are public via §8.3, so without this an attacker who reads one
// could re-register it under their own account and start receiving
// that device's mail.
if let existing, existing.userID != caller.userID {
throw WireError.forbidden("That device belongs to another account.")
}
let device = existing ?? Device()
if existing == nil { device.id = input.device_id }
device.userID = caller.userID
device.ikDH = input.ik_dh
device.ikSig = input.ik_sig
device.spkID = input.spk_id
device.spk = input.spk
device.spkSig = input.spk_sig
// Never downgrades: a device that has proven v2 does not become v1
// again because one call omitted the block.
device.proto = max(existing?.proto ?? 1, input.proto ?? 1)
device.capsSig = input.caps_sig ?? existing?.capsSig
device.pqSpk = input.pqspk ?? existing?.pqSpk
device.pqSpkSig = input.pqspk_sig ?? existing?.pqSpkSig
device.pqSpkID = input.pqspk != nil
? (input.pqspk_id ?? 0) : (existing?.pqSpkID ?? 0)
// §20. Sizes are checked here because a wrong-length key can only
// ever fail verification on every peer, and failing at publication
// names the problem once instead of once per correspondent.
if let pq = input.ik_pq {
guard Data(base64Encoded: pq)?.count == 1952 else {
throw WireError.badRequest("ik_pq must be a base64 1952-byte ML-DSA-65 key.")
}
device.ikPQ = pq
} else {
device.ikPQ = existing?.ikPQ
}
if let sig = input.spk_pq_sig {
guard Data(base64Encoded: sig)?.count == 3309 else {
throw WireError.badRequest("spk_pq_sig must be a base64 3309-byte ML-DSA-65 signature.")
}
device.spkPQSig = sig
} else {
device.spkPQSig = existing?.spkPQSig
}
let identityChanged = existing == nil
|| existing?.ikSig != input.ik_sig
|| existing?.ikDH != input.ik_dh
if existing == nil { try await device.create(on: db) } else { try await device.update(on: db) }
// §19: a key the relay will serve must first be a key the relay has
// committed to. In the same transaction as the write, or the log
// could disagree with the table it is supposed to be a record of.
//
// Only when the identity actually changed: a replenishment or an
// SPK rotation republishes the same identity, and appending on
// every such call would bury the entries that mean something.
if identityChanged {
try await KeyLog.append(
userID: caller.userID, deviceID: input.device_id,
ikSig: input.ik_sig, ikDH: input.ik_dh, kind: "publish", on: db)
}
try await insertPreKeys(input.prekeys, input.pq_prekeys,
device: input.device_id, on: db)
}
return try .json(RegisterDeviceResponse(
ok: true,
device_id: input.device_id,
opk_remaining: try await poolSize(PreKey.self, input.device_id, on: request.db),
pq_opk_remaining: (input.proto ?? 1) >= 2
? try await poolSize(PQPreKey.self, input.device_id, on: request.db) : nil
))
}
// MARK: - §8.2 GET /e2e/keys/{user_id}
func bundles(_ request: Request, target: Int, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/keys/\(target)", now: now)
// Metered before a single prekey is popped: the cost being guarded is
// the pop, not the query. Per requester AND per requester-target pair,
// never per target alone — a per-target cap would let one abuser make a
// popular account unstartable for everybody, trading a forward-secrecy
// downgrade for a denial of service.
let admitted = await limits.consumeAll([
(key: "bundle:\(caller.userID)", amount: 1, limit: config.bundleRatePerHour),
(key: "bundle:\(caller.userID):\(target)", amount: 1, limit: config.bundleRatePerTarget),
], now: now)
guard admitted else {
throw WireError.rateLimited("Too many key requests recently. Try again shortly.")
}
let devices = try await Device.query(on: request.db)
.filter(\.$userID == target).all()
guard !devices.isEmpty else { throw WireError.notFound("No such user.") }
var out: [BundleResponse.Bundle] = []
for d in devices {
guard let deviceID = d.id else { continue }
var bundle = BundleResponse.Bundle(
device_id: deviceID,
ik_dh: d.ikDH, ik_sig: d.ikSig,
spk_id: d.spkID, spk: d.spk, spk_sig: d.spkSig,
opk: try await popPreKey(deviceID, on: request.db)
)
if d.hasCompleteV2Block {
bundle.proto = d.proto
bundle.caps_sig = d.capsSig
bundle.pqspk_id = d.pqSpkID
bundle.pqspk = d.pqSpk
bundle.pqspk_sig = d.pqSpkSig
// Null when the PQ pool is drained: the client then encapsulates
// to the last-resort key, still v2, at the cost of per-session
// PQ forward secrecy (spec §12.3).
bundle.pqopk = try await popPQPreKey(deviceID, on: request.db)
}
// §20, independent of the v2 block: post-quantum *authentication*
// and post-quantum *key agreement* are separate capabilities, and a
// device can have either without the other. Gating this on
// `hasCompleteV2Block` would hide an identity a peer must verify.
bundle.ik_pq = d.ikPQ
bundle.spk_pq_sig = d.spkPQSig
out.append(bundle)
}
return try .json(BundleResponse(user_id: target, devices: out))
}
// MARK: - §8.3 GET /e2e/devices/{user_id}
func listDevices(_ request: Request, target: Int, now: Int) async throws -> Response {
_ = try await authenticate(
request, body: Data(), path: "/e2e/devices/\(target)", now: now)
let devices = try await Device.query(on: request.db)
.filter(\.$userID == target).all()
return try .json(DeviceListResponse(
user_id: target,
devices: devices.compactMap { d in
d.id.map { .init(device_id: $0, ik_dh: d.ikDH, ik_sig: d.ikSig, proto: d.proto) }
}))
}
// MARK: - §8.3a DELETE /e2e/devices/{device_id}
func revokeDevice(_ request: Request, deviceID: String, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/devices/\(deviceID)", now: now)
guard isValidDeviceID(deviceID) else {
throw WireError.badRequest("Malformed device id.")
}
guard let device = try await Device.find(deviceID, on: request.db) else {
throw WireError.notFound("No such device.")
}
// Checked explicitly rather than folded into the delete, so revoking
// someone else's device is a refusal rather than a no-op that looks
// like success.
guard device.userID == caller.userID else {
throw WireError.forbidden("That device belongs to another account.")
}
// Its prekey pools go with it, or the rows outlive the identity they
// were published under. Queued envelopes go too: nothing can ever read
// them again, and keeping them is only a record of who wrote to a
// device that no longer exists.
try await request.db.transaction { db in
try await PreKey.query(on: db).filter(\.$deviceID == deviceID).delete()
try await PQPreKey.query(on: db).filter(\.$deviceID == deviceID).delete()
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.
try await KeyLog.append(
userID: device.userID, deviceID: deviceID,
ikSig: device.ikSig, ikDH: device.ikDH, kind: "revoke", on: db)
try await device.delete(on: db)
}
return try .json(RevokeResponse(ok: true, device_id: deviceID))
}
// MARK: - §14.4 POST /e2e/delivery-key
/// Publish `SHA-256(delivery_key)` for the calling account.
///
/// The relay never receives the key itself here — only its digest. The key
/// travels to contacts inside E2E messages, which is what keeps the relay
/// from learning who is permitted to send to whom.
///
/// Republishing replaces the digest, and that is the whole anti-spam story
/// (§14.6): rotating the key is how a user cuts off a sender, at the cost
/// of redistributing it to everyone they still want to hear from.
func publishDeliveryKey(_ request: Request, body: Data, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: body, path: "/e2e/delivery-key", now: now)
let input = try decodeBody(DeliveryKeyRequest.self, from: body)
// A digest is 32 bytes. Accepting anything else would let a client
// publish something no sealed submission could ever match, locking
// itself out of sealed delivery with no error to explain why.
guard let digest = Data(base64Encoded: input.digest), digest.count == 32 else {
throw WireError.badRequest("digest must be a base64 32-byte SHA-256.")
}
if let existing = try await DeliveryKey.find(caller.userID, on: request.db) {
existing.digest = digest.base64EncodedString()
try await existing.update(on: request.db)
} else {
let row = DeliveryKey()
row.id = caller.userID
row.digest = digest.base64EncodedString()
try await row.create(on: request.db)
}
return try .json(OKResponse(ok: true))
}
// MARK: - §8.4 POST /e2e/messages
/// The one route whose auth is deliberately not the token.
///
/// A sealed submission presents the recipient's delivery key and is
/// otherwise unauthenticated, exactly as the PHP relay had it. Requiring a
/// token here would hand the relay the sender's identity at request time,
/// so sealing would hide the sender from the stored row and from nobody
/// else — strictly worse than what it replaced.
///
/// The abuse problem this creates is not solved here either: sealed traffic
/// is metered by recipient and by IP, because there is no sender to meter.
/// Signal's answer is delivery certificates, which verify that a sender is
/// permitted without revealing who they are. That is the eventual shape.
func send(_ request: Request, body: Data, clientIP: String, now: Int) async throws -> Response {
let input = try decodeBody(SendRequest.self, from: body)
guard !input.messages.isEmpty else { throw WireError.badRequest("No envelopes.") }
guard input.messages.count <= 50 else {
throw WireError.badRequest("Too many envelopes in one request.")
}
let sealed = input.delivery_key?.isEmpty == false
var fromUser = 0
var fromDevice = ""
if sealed {
guard let raw = Data(base64Encoded: input.delivery_key!), raw.count == 32 else {
throw Self.sealedRejection
}
// Metered before the capability is checked, so the digest
// comparison cannot be probed for free.
//
// Both axes are needed. Recipient alone lets one abuser exhaust a
// victim's quota; IP alone is trivially rotated.
let n = input.messages.count
let ok = await limits.consumeAll([
(key: "send:to:\(input.to)", amount: n, limit: config.sendRatePerHour),
(key: "send:ip:\(clientIP)", amount: n, limit: config.sendRatePerHour),
], now: now)
guard ok else { throw WireError.rateLimited("Too many messages sent recently.") }
// §14.4: the presented key must hash to the digest the recipient
// published. Without this the "sealed" mode is not an
// authorisation at all — any well-formed 32 bytes would reach any
// inbox, and rotating the key would cut off nobody.
let presented = Data(SHA256.hash(data: raw))
guard let published = try await DeliveryKey.find(input.to, on: request.db),
let expected = Data(base64Encoded: published.digest),
constantTimeEquals(presented, expected)
else { throw Self.sealedRejection }
} else {
let caller = try await authenticate(
request, body: body, path: "/e2e/messages", now: now)
fromUser = caller.userID
fromDevice = input.from_device ?? caller.deviceID
guard isValidDeviceID(fromDevice) else {
throw WireError.badRequest("Malformed from_device.")
}
let ok = await limits.consume(
"send:from:\(caller.userID)", amount: input.messages.count,
limit: config.sendRatePerHour, now: now)
guard ok else { throw WireError.rateLimited("Too many messages sent recently.") }
}
// An envelope may only be addressed to a device the stated recipient
// actually owns. Inboxes are read by to_device alone, so without this
// anyone could drop envelopes into any inbox — device ids are public.
let owned = Set(try await Device.query(on: request.db)
.filter(\.$userID == input.to).all().compactMap(\.id))
guard !owned.isEmpty else {
// Sealed traffic gets the one generic refusal whatever the reason,
// so the route cannot be used to ask "does this account exist" or
// "has this account published a delivery key" (§14.4).
throw sealed ? Self.sealedRejection : WireError.notFound("No such user.")
}
// Bound to immutable copies before crossing into the transaction
// closure: the sealed/identified decision above is made once, and a var
// captured by a concurrently-executing closure could not be reasoned
// about at all.
let senderUser = fromUser
let senderDevice = fromDevice
let ids: [Int] = try await request.db.transaction { db in
var written: [Int] = []
for m in input.messages where owned.contains(m.to_device) {
let e = Envelope()
e.toUser = input.to
e.toDevice = m.to_device
e.fromUser = senderUser
e.fromDevice = senderDevice
e.payload = m.payload
e.delivered = false
try await e.create(on: db)
if let id = e.id { written.append(id) }
}
return written
}
return try .json(SendResponse(ok: true, ids: ids))
}
// MARK: - §8.5 GET /e2e/messages
func poll(_ request: Request, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/messages", now: now)
let deviceID = request.query[String.self, at: "device_id"] ?? caller.deviceID
// The token is device-scoped, so an inbox can only be read by the
// device it was minted for. A user-scoped token would have let one
// stolen from a browser drain the phone's inbox.
guard deviceID == caller.deviceID else {
throw WireError.forbidden("Token is scoped to a different device.")
}
let after = request.query[Int.self, at: "after"] ?? 0
let limit = min(request.query[Int.self, at: "limit"] ?? 100, 500)
let rows = try await Envelope.query(on: request.db)
.filter(\.$toDevice == deviceID)
.filter(\.$id > after)
.sort(\.$id, .ascending)
.limit(limit)
.all()
var cursor = after
let messages = rows.map { e -> PollResponse.Message in
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))
}
if !messages.isEmpty {
try await Envelope.query(on: request.db)
.filter(\.$toDevice == deviceID)
.filter(\.$id <= cursor)
.set(\.$delivered, to: true)
.update()
}
return try .json(PollResponse(messages: messages, cursor: cursor))
}
// MARK: - §8.6 POST /e2e/prekeys
func replenish(_ request: Request, body: Data, now: Int) async throws -> Response {
let caller = try await authenticate(request, body: body, path: "/e2e/prekeys", now: now)
let input = try decodeBody(ReplenishRequest.self, from: body)
guard input.device_id == caller.deviceID else {
throw WireError.forbidden("Token is scoped to a different device.")
}
try await request.db.transaction { db in
try await insertPreKeys(input.prekeys, input.pq_prekeys,
device: input.device_id, on: db)
}
return try .json(ReplenishResponse(
ok: true,
opk_remaining: try await poolSize(PreKey.self, input.device_id, on: request.db),
pq_opk_remaining: try await poolSize(PQPreKey.self, input.device_id, on: request.db)))
}
// MARK: - Helpers
/// Duplicate prekey ids are ignored rather than rejected: a client retrying
/// a registration whose response it never saw must not be refused, or it
/// can never recover.
private func insertPreKeys(
_ classical: [PreKeyIn]?,
_ pq: [PQPreKeyIn]?,
device: String,
on db: any Database
) async throws {
let held = Set(try await PreKey.query(on: db)
.filter(\.$deviceID == device).all().map(\.preKeyID))
for k in classical ?? [] where !held.contains(k.id) {
let row = PreKey()
row.deviceID = device; row.preKeyID = k.id; row.pub = k.pub; row.used = false
try await row.create(on: db)
}
let heldPQ = Set(try await PQPreKey.query(on: db)
.filter(\.$deviceID == device).all().map(\.preKeyID))
for k in pq ?? [] where !heldPQ.contains(k.id) {
let row = PQPreKey()
row.deviceID = device; row.preKeyID = k.id
row.pub = k.pub; row.sig = k.sig; row.used = false
try await row.create(on: db)
}
}
private func poolSize<M: Model>(
_ type: M.Type, _ deviceID: String, on db: any Database
) async throws -> Int where M: PreKeyPool {
try await M.query(on: db)
.filter(M.deviceKey, .equal, deviceID)
.filter(M.usedKey, .equal, false)
.count()
}
/// Claim one unused prekey.
///
/// One statement, not a select-then-update: the subquery picks the lowest
/// unused id and the UPDATE claims it atomically, so two concurrent bundle
/// fetches cannot be handed the same one-time key. RETURNING gives back the
/// row that was actually claimed, which is the only way to know *which* one
/// this caller won.
private func popPreKey(_ deviceID: String, on db: any Database) async throws
-> BundleResponse.OneTime?
{
guard let sql = db as? any SQLDatabase else { return nil }
let rows = try await sql.raw("""
UPDATE prekeys SET used = true
WHERE id = (SELECT id FROM prekeys
WHERE device_id = \(bind: deviceID) AND used = false
ORDER BY id ASC LIMIT 1)
RETURNING prekey_id, pub
""").all()
guard let row = rows.first,
let id = try? row.decode(column: "prekey_id", as: Int.self),
let pub = try? row.decode(column: "pub", as: String.self)
else { return nil }
return .init(id: id, pub: pub)
}
private func popPQPreKey(_ deviceID: String, on db: any Database) async throws
-> BundleResponse.PQOneTime?
{
guard let sql = db as? any SQLDatabase else { return nil }
let rows = try await sql.raw("""
UPDATE pq_prekeys SET used = true
WHERE id = (SELECT id FROM pq_prekeys
WHERE device_id = \(bind: deviceID) AND used = false
ORDER BY id ASC LIMIT 1)
RETURNING prekey_id, pub, sig
""").all()
guard let row = rows.first,
let id = try? row.decode(column: "prekey_id", as: Int.self),
let pub = try? row.decode(column: "pub", as: String.self),
let sig = try? row.decode(column: "sig", as: String.self)
else { return nil }
return .init(id: id, pub: pub, sig: sig)
}
/// Delivery keys are minted and held by the recipient's client, not here.
/// The relay can only check the shape; binding a key to a recipient is the
/// identity provider's job and is not part of this port.
private func deliveryKeyWellFormed(_ key: String) -> Bool {
Data(base64Encoded: key)?.count == 32
}
/// The single refusal every sealed rejection uses.
///
/// A malformed key, an unpublished one, a wrong one, and an account that
/// does not exist must be indistinguishable. Distinct errors would turn
/// the one unauthenticated route into an oracle for who is reachable.
static let sealedRejection = WireError.unauthorized("Delivery key not accepted.")
func decodeBody<T: Decodable>(_ type: T.Type, from body: Data) throws -> T {
do { return try JSONDecoder().decode(type, from: body) }
catch { throw WireError.badRequest("Malformed request body.") }
}
}
/// Lets `poolSize` count either pool without duplicating the query.
protocol PreKeyPool {
static var deviceKey: FieldKey { get }
static var usedKey: FieldKey { get }
}
extension PreKey: PreKeyPool {
static var deviceKey: FieldKey { "device_id" }
static var usedKey: FieldKey { "used" }
}
extension PQPreKey: PreKeyPool {
static var deviceKey: FieldKey { "device_id" }
static var usedKey: FieldKey { "used" }
}
func isValidDeviceID(_ id: String) -> Bool {
!id.isEmpty && id.count <= 64 && id.allSatisfy { $0.isHexDigit || $0 == "-" }
}
/// Spec §8.5 shows `created_at` as an ISO-8601 UTC instant.
func iso8601(_ date: Date?) -> String {
let f = ISO8601DateFormatter()
f.timeZone = TimeZone(identifier: "UTC")
return f.string(from: date ?? Date())
}
+514
View File
@@ -0,0 +1,514 @@
import Crypto
import Fluent
import Foundation
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
/// long-lived credential, then exchange that credential for short-lived
/// device-bound relay tokens.
struct AccountRoutes: Sendable {
let issuer: Issuer
let limits: RateLimiter
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
/// This route turns a password into a long-lived credential, so without a
/// throttle it is a free offline-speed password oracle reachable over HTTP.
/// Keyed on the pair rather than either alone: per-username lets anyone lock
/// out an account they do not own, and per-IP alone is trivially rotated.
static let signinMaxAttempts = 5
static let signinLockout = 900
/// Member lookups per caller per window. Generous for someone typing a
/// username into a search field, useless for walking a name list.
static let lookupMaxPerWindow = 20
// MARK: - POST /account/signup
func signup(_ request: Request) async throws -> Response {
let input = try request.content.decode(SignupRequest.self)
let username = User.normalize(input.username)
// These codes are not arbitrary: the shipped client switches on them to
// pick its own error copy, and anything else falls through to a generic
// failure. `bad_request` would have rendered as "something went wrong"
// for every validation problem.
guard User.isValidUsername(username) else {
throw WireError("bad_username",
"Usernames are 3–60 characters, lowercase letters and digits only.", 400)
}
guard isPlausibleEmail(input.email) else {
throw WireError("bad_email", "A valid email address is required.", 400)
}
// Length only, and no composition rules. Rules push people toward
// Password1! and the throttle above is what actually bounds guessing.
guard input.password.count >= 8 else {
throw WireError("weak_password", "Passwords must be at least 8 characters.", 400)
}
let email = input.email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if try await User.query(on: request.db).filter(\.$username == username).first() != nil {
throw WireError("username_taken", "That username is taken.", 409)
}
if try await User.query(on: request.db).filter(\.$email == email).first() != nil {
throw WireError("email_taken", "That email is already registered.", 409)
}
let user = User()
user.username = username
user.email = email
user.displayName = (input.display_name?.trimmingCharacters(in: .whitespacesAndNewlines))
.flatMap { $0.isEmpty ? nil : $0 } ?? input.username
user.passwordHash = try Bcrypt.hash(input.password)
user.chosenStatus = ChosenStatus.online.rawValue
try await user.create(on: request.db)
guard let userID = user.id else {
throw WireError("server_error", "Could not create the account.", 500)
}
let secret = try await mintCredential(
userID: userID, label: "signup", on: request.db)
return try .json(AccountResponse(
user_id: userID, username: user.username,
display_name: user.displayName, app_password: secret))
}
// MARK: - POST /account/signin
func signin(_ request: Request, clientIP: String, now: Int) async throws -> Response {
let input = try request.content.decode(SigninRequest.self)
let username = User.normalize(input.username)
let throttleKey = "signin:\(clientIP)|\(username)"
guard await limits.peek(throttleKey, limit: Self.signinMaxAttempts, now: now) else {
throw WireError.rateLimited("Too many sign-in attempts. Try again shortly.")
}
// The client's field is "username or email" and always has been, so a
// server that only matched usernames would reject half the people who
// typed exactly what they were asked for.
let typed = input.username.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let user = try await User.query(on: request.db)
.group(.or) { $0.filter(\.$username == username).filter(\.$email == typed) }
.first()
// Verify against a dummy hash when the user does not exist, so that a
// missing account and a wrong password take the same time. Otherwise
// the response time enumerates who has an account here, which for a
// community-anchored product is a membership list.
let hash = user?.passwordHash ?? Self.dummyHash
let ok = (try? Bcrypt.verify(input.password, created: hash)) ?? false
guard ok, let user, let userID = user.id else {
_ = await limits.consume(throttleKey, limit: Self.signinMaxAttempts, now: now)
throw WireError.unauthorized("Incorrect username or password.")
}
await limits.clear(throttleKey)
let secret = try await mintCredential(userID: userID, label: "signin", on: request.db)
return try .json(AccountResponse(
user_id: userID, username: user.username,
display_name: user.displayName, app_password: secret))
}
// MARK: - POST /messenger/session — a shared FlameNet account
/// Exchange a FlameNet account session for a relay session.
///
/// The account service authenticates the person; this decides which local
/// account that is. Its token carries a **product-scoped pseudonym** and
/// deliberately not the handle, so this relay learns an opaque id and
/// nothing that ties it to any other FlameNet product.
///
/// That opacity is the reason the client sends a handle alongside it. A
/// messenger needs a name people can be found by, and the token supplies
/// none — so on the **first** link the caller names themselves, and every
/// sign-in afterwards ignores it and uses the pseudonym. The handle is a
/// display name here, not a credential: what proves who somebody is, is
/// the signature on the token.
///
/// 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 {
let input = try request.content.decode(EnclaveSessionRequest.self)
let claims = try Self.verifyEnclaveToken(input.token, secret: secret, now: now)
// Look up by pseudonym first: after the first sign-in the handle is
// irrelevant, and honouring it would let somebody rename themselves
// into an existing account.
if let existing = try await User.query(on: request.db)
.filter(\.$enclavePseudonym == claims.subject).first(),
let userID = existing.id {
let credential = try await mintCredential(userID: userID, label: "flamenet", on: request.db)
return try .json(AccountResponse(
user_id: userID, username: existing.username,
display_name: existing.displayName, app_password: credential))
}
// First time this FlameNet account has been seen here.
let username = User.normalize(input.handle)
guard User.isValidUsername(username) else {
throw WireError("bad_username",
"Usernames are 3–60 characters, lowercase letters and digits only.", 400)
}
if try await User.query(on: request.db).filter(\.$username == username).first() != nil {
throw WireError("username_taken",
"Somebody already uses that name on this server. Pick another.", 409)
}
let user = User()
user.username = username
// No email and no password: this account is authenticated elsewhere,
// and storing a credential it never uses would be a credential that
// can leak for nothing.
user.email = nil
user.passwordHash = ""
user.displayName = input.display_name?.isEmpty == false ? input.display_name! : username
user.enclavePseudonym = claims.subject
try await user.create(on: request.db)
guard let userID = user.id else { throw WireError.badRequest("Could not create the account.") }
let credential = try await mintCredential(userID: userID, label: "flamenet", on: request.db)
return try .json(AccountResponse(
user_id: userID, username: user.username,
display_name: user.displayName, app_password: credential))
}
/// Minimal HS256 verification for the account service's token.
///
/// Deliberately small and deliberately strict. It checks the algorithm in
/// 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.
static func verifyEnclaveToken(_ token: String, secret: String,
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"
else { throw WireError.unauthorized("Unsupported token algorithm.") }
let signed = "\(parts[0]).\(parts[1])"
let expected = HMAC<SHA256>.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 payloadData = Data(base64URLEncoded: String(parts[1])),
let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
let subject = payload["sub"] as? String, !subject.isEmpty
else { throw WireError.unauthorized("Token has no subject.") }
// `exp` arrives as a float; a token without one is refused rather than
// treated as eternal.
guard let exp = payload["exp"] as? Double else {
throw WireError.unauthorized("Token has no expiry.")
}
guard exp > Double(now) else { throw WireError.unauthorized("Token has expired.") }
return (subject, exp)
}
// MARK: - GET /wp/v2/users/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.
func me(_ request: Request) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db),
let user = try await User.find(userID, on: request.db)
else { throw WireError.unauthorized("Credential not accepted.") }
return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username))
}
// MARK: - GET /wp/v2/users?slug=<username>
/// Look up one member by exact username.
///
/// Deliberately *not* a directory. The WordPress 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
/// keeps the route it already calls and only stops omitting the parameter.
///
/// Authenticated on purpose: an open lookup is an anonymous
/// does-this-username-exist oracle. Requiring a credential does not stop a
/// member from probing — the throttle below is what bounds that — but it
/// does mean probing costs an account.
func lookup(_ request: Request, now: Int) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let callerID = try await verifyAppCredential(basic.password, on: request.db)
else { throw WireError.unauthorized("Credential required.") }
let slug = User.normalize(request.query[String.self, at: "slug"] ?? "")
guard !slug.isEmpty else {
// Not an empty array: a client that lost its query parameter would
// read that as "this server has no members" and show an empty
// directory, which is a worse lie than an error.
throw WireError("bad_request",
"Look up a member by exact username with ?slug=<username>. "
+ "This relay does not serve a member list.", 400)
}
// One username per request is still an existence oracle; this is what
// keeps it from being a bulk one. Keyed on the caller rather than the
// address, because the address is the cheaper of the two to change.
guard await limits.consume("lookup:\(callerID)", limit: Self.lookupMaxPerWindow, now: now) else {
throw WireError.rateLimited("Too many lookups. Try again shortly.")
}
guard let user = try await User.query(on: request.db)
.filter(\.$username == slug).first(),
let id = user.id
else {
// A miss and a hit differ only in the array being empty, and both
// cost a lookup against the throttle, so neither timing nor budget
// distinguishes them.
return try .json([MeResponse]())
}
return try .json([MeResponse(id: id, name: user.displayName, slug: user.username)])
}
// MARK: - POST /account/token
/// Exchange the long-lived credential for a short-lived, device-bound relay
/// token.
///
/// The caller states which device the token is for and which key it should
/// be bound to. Both end up in the token, and the relay enforces them —
/// which is what stops a token minted for a browser from being usable by,
/// or against, a phone.
func token(_ request: Request, now: Int) async throws -> Response {
guard let basic = request.headers.basicAuthorization else {
throw WireError.unauthorized("Credential required.")
}
let input = try request.content.decode(TokenRequest.self)
guard isValidDeviceID(input.device_id) else {
throw WireError.badRequest("Malformed device id.")
}
guard let ikSig = Data(base64Encoded: input.ik_sig), ikSig.count == 32,
(try? Curve25519.Signing.PublicKey(rawRepresentation: ikSig)) != nil
else {
throw WireError.badRequest("ik_sig must be a base64 32-byte Ed25519 public key.")
}
// The credential alone identifies the account — the username half of
// Basic auth is decorative here, and checking it would only add a way
// to fail with the right credential. It is accepted and ignored.
guard let userID = try await verifyCredential(basic.password, on: request.db) else {
throw WireError.unauthorized("Credential not accepted.")
}
// A device registered to someone else must not get a token naming this
// account: the relay would then see a token and a device row that
// disagree, and it refuses — but failing here says why.
if let existing = try await Device.find(input.device_id, on: request.db),
existing.userID != userID {
throw WireError.forbidden("That device belongs to another account.")
}
let token = try issuer.mint(
userID: userID, deviceID: input.device_id, identityKey: ikSig, now: now)
return try .json(TokenResponse(
token: token, expires_in: issuer.lifetime, audience: issuer.audience))
}
// MARK: - Credentials
/// A credential is `<selector>.<secret>`. The selector is a lookup key held
/// in the clear; only the secret's hash is stored, so a database read does
/// not yield anything usable.
private func mintCredential(userID: Int, label: String, on db: any Database) async throws
-> String
{
let selector = String(randomHexToken().prefix(12))
let secret = randomHexToken()
let row = AppCredential()
row.userID = userID
row.selector = selector
row.secretHash = try Bcrypt.hash(secret)
row.label = label
try await row.create(on: db)
return "\(selector).\(secret)"
}
/// Also used by the mobile compatibility layer, which authenticates the
/// shipped iOS client with the same credential over HTTP Basic.
func verifyAppCredential(_ presented: String, on db: any Database) async throws -> Int? {
try await verifyCredential(presented, on: db)
}
private func verifyCredential(_ presented: String, on db: any Database) async throws -> Int? {
let parts = presented.split(separator: ".", maxSplits: 1)
guard parts.count == 2 else { return nil }
guard let row = try await AppCredential.query(on: db)
.filter(\.$selector == String(parts[0])).first()
else { return nil }
guard (try? Bcrypt.verify(String(parts[1]), created: row.secretHash)) == true else {
return nil
}
row.lastUsedAt = Date()
try? await row.update(on: db)
return row.userID
}
/// A real bcrypt hash of a value nothing will ever present, used to keep
/// the timing of "no such user" identical to "wrong password".
private static let dummyHash =
"$2b$12$C6UzMDM.H6dfI/f/IKcEe.7BEnJXo4jFmBH3nXWa5FRuBAbUiFbW."
private func isPlausibleEmail(_ s: String) -> Bool {
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 6, t.count <= 254, !t.contains(" ") else { return false }
let parts = t.split(separator: "@")
guard parts.count == 2, !parts[0].isEmpty else { return false }
return parts[1].contains(".") && !parts[1].hasPrefix(".") && !parts[1].hasSuffix(".")
}
}
// MARK: - Profile and account removal
extension AccountRoutes {
/// `POST /wp/v2/users/me` — change the display name, or the password.
///
/// WordPress accepted both fields on this route and 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.
func updateMe(_ request: Request) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db),
let user = try await User.find(userID, on: request.db)
else { throw WireError.unauthorized("Credential not accepted.") }
let input = try request.content.decode(UpdateMeRequest.self)
guard input.name != nil || input.password != nil else {
throw WireError.badRequest("Nothing to change.")
}
if let raw = input.name {
let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard (1...60).contains(name.count) else {
throw WireError("bad_display_name",
"Display names are 1–60 characters.", 400)
}
user.displayName = name
}
if let password = input.password {
// An account authenticated by the shared FlameNet service has no
// password here to change: the verifier lives in that service, and
// this row's `password_hash` is empty by construction. Writing one
// would create a second credential that signs in nowhere and looks
// to the user like the change succeeded.
guard user.enclavePseudonym == nil else {
throw WireError("shared_account",
"This account signs in with your FlameNet passphrase, so it is changed "
+ "with your FlameNet account rather than here.", 409)
}
guard password.count >= 8 else {
throw WireError("weak_password", "Passwords must be at least 8 characters.", 400)
}
user.passwordHash = try Bcrypt.hash(password)
}
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
// 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".
return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username))
}
/// `DELETE /messenger/account` — remove the account and everything keyed to it.
///
/// Deletes rather than flags. A messenger that answers "delete my account"
/// with a hidden row still holds the keys, the envelopes and the buddy
/// graph, and the only honest version of this route is the one where the
/// rows stop existing.
///
/// Not covered here, and said plainly rather than implied: this cannot
/// reach ciphertext already delivered to somebody else's device, and on a
/// relay wired to the shared FlameNet account service it removes the
/// account **on this relay only** — the FlameNet account itself is deleted
/// where it lives.
func deleteAccount(_ request: Request) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db),
let user = try await User.find(userID, on: request.db)
else { throw WireError.unauthorized("Credential not accepted.") }
let db = request.db
// Devices first, because prekeys and envelopes are keyed by device id
// and a crash midway should not leave a device advertising prekeys that
// no longer exist — a bundle fetch would hand a sender a device it can
// never deliver to.
let deviceIDs = try await Device.query(on: db)
.filter(\.$userID == userID).all().compactMap(\.id)
for deviceID in deviceIDs {
try await PreKey.query(on: db).filter(\.$deviceID == deviceID).delete()
try await PQPreKey.query(on: db).filter(\.$deviceID == deviceID).delete()
try await Envelope.query(on: db).filter(\.$toDevice == deviceID).delete()
try await Envelope.query(on: db).filter(\.$fromDevice == deviceID).delete()
}
try await Device.query(on: db).filter(\.$userID == userID).delete()
// Group rows the account owns go with it; membership elsewhere is just
// a row to drop.
let owned = try await Group.query(on: db)
.filter(\.$ownerUserID == userID).all().compactMap(\.id)
for gid in owned {
try await GroupMember.query(on: db).filter(\.$gid == gid).delete()
}
try await Group.query(on: db).filter(\.$ownerUserID == userID).delete()
try await GroupMember.query(on: db).filter(\.$userID == userID).delete()
// The buddy graph is symmetric: leaving the rows that point AT this
// account would leave other people with a buddy who no longer exists.
try await Buddy.query(on: db).filter(\.$userID == userID).delete()
try await Buddy.query(on: db).filter(\.$buddyID == userID).delete()
try await Block.query(on: db).filter(\.$userID == userID).delete()
try await Block.query(on: db).filter(\.$blockedID == userID).delete()
try await ConversationState.query(on: db).filter(\.$userID == userID).delete()
try await ConversationState.query(on: db).filter(\.$otherID == userID).delete()
try await Message.query(on: db).filter(\.$fromID == userID).delete()
try await Message.query(on: db).filter(\.$toID == userID).delete()
try await Notification.query(on: db).filter(\.$userID == userID).delete()
try await Envelope.query(on: db).filter(\.$toUser == userID).delete()
try await Envelope.query(on: db).filter(\.$fromUser == userID).delete()
try await DeliveryKey.query(on: db).filter(\.$id == userID).delete()
// Credentials last: while they exist the account is still reachable,
// so they are what makes the deletion take effect.
try await AppCredential.query(on: db).filter(\.$userID == userID).delete()
try await user.delete(on: db)
return try .json(MobileMessageOnly(message: "Account deleted."))
}
}
/// Both fields optional: the client sends one or the other, never both.
struct UpdateMeRequest: Content {
let name: String?
let password: String?
}
@@ -0,0 +1,126 @@
import Crypto
import Fluent
import Foundation
import Vapor
/// §8.7/§8.8 — sealed attachment blobs.
///
/// The body is raw ciphertext, not JSON: `nonce || ct || tag` already sealed
/// client-side (§6.6). The relay never sees a key and cannot distinguish the
/// blob from noise, which is also why the only limits it can impose are size
/// and rate.
extension Routes {
// MARK: - POST /e2e/attachments
func uploadAttachment(_ request: Request, body: Data, now: Int) async throws -> Response {
// The proof signs a digest of the body, which here is the whole blob.
// That is deliberate: it means an upload cannot be redirected to a
// different payload by replaying one request's headers.
let caller = try await authenticate(
request, body: body, path: "/e2e/attachments", now: now)
// An AES-GCM sealed box is at least nonce(12) + tag(16). Anything
// shorter cannot decrypt to anything and is a client bug or a probe.
guard body.count >= 28 else {
throw WireError.badRequest("Attachment body required.")
}
guard body.count <= config.attachmentMaxBytes else {
throw WireError("too_large", "Attachment exceeds the size limit.", 413)
}
let admitted = await limits.consumeAll([
(key: "att:count:\(caller.userID)", amount: 1, limit: config.attachmentRatePerHour),
(key: "att:bytes:\(caller.userID)", amount: body.count,
limit: config.attachmentBytesPerHour),
], now: now)
guard admitted else {
throw WireError.rateLimited("Too many attachments uploaded recently. Try again shortly.")
}
// Possession of this token IS the download capability — it only ever
// travels inside the ratchet — so it has to be unguessable, not merely
// unique.
let token = randomHexToken()
let directory = attachmentsDirectory(request.application)
try FileManager.default.createDirectory(
atPath: directory, withIntermediateDirectories: true)
let path = attachmentPath(directory, token)
do {
try body.write(to: URL(fileURLWithPath: path), options: .atomic)
} catch {
request.logger.error("attachment write failed: \(error)")
throw WireError("storage_error", "Could not store attachment.", 500)
}
do {
let row = Attachment()
row.id = token
row.bytes = body.count
try await row.create(on: request.db)
} catch {
// The row is the only thing retention can see. A blob on disk with
// no row would never be purged and never be downloadable — a leak
// that grows.
try? FileManager.default.removeItem(atPath: path)
request.logger.error("attachment index failed: \(error)")
throw WireError("storage_error", "Could not index attachment.", 500)
}
return try .json(AttachmentUploadResponse(ok: true, id: token, bytes: body.count))
}
// MARK: - GET /e2e/attachments/{id}
/// Any authenticated member may download, because the unguessable id is the
/// gate — the relay cannot know the intended recipient of a sealed
/// reference, so it has no basis to decide. This mirrors the Signal CDN
/// model, and it is also the reason the id must never appear in a URL that
/// gets logged or refer-leaked outside the ratchet.
func downloadAttachment(_ request: Request, token: String, now: Int) async throws -> Response {
_ = try await authenticate(
request, body: Data(), path: "/e2e/attachments/\(token)", now: now)
guard isValidAttachmentToken(token) else {
throw WireError.badRequest("Malformed attachment id.")
}
guard try await Attachment.find(token, on: request.db) != nil else {
throw WireError.notFound("No such attachment.")
}
let path = attachmentPath(attachmentsDirectory(request.application), token)
guard FileManager.default.fileExists(atPath: path) else {
// Indexed but absent: retention removed the bytes, or the write
// half-failed. Either way it is gone, and saying 404 is honest.
throw WireError.notFound("No such attachment.")
}
let response = request.fileio.streamFile(at: path)
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
return response
}
func attachmentsDirectory(_ app: Application) -> String {
let configured = config.attachmentsDirectory
if configured.hasPrefix("/") { return configured }
return app.directory.workingDirectory + configured
}
}
func attachmentPath(_ directory: String, _ token: String) -> String {
"\(directory)/\(token).bin"
}
/// 64 hex characters, and nothing else — the value is interpolated into a file
/// path, so anything that could contain `/` or `..` must not reach it.
func isValidAttachmentToken(_ token: String) -> Bool {
token.count == 64 && token.allSatisfy { $0.isHexDigit && !$0.isUppercase }
}
/// 32 CSPRNG bytes, hex-encoded. Used for attachment ids and group ids, both of
/// which are capabilities rather than names.
func randomHexToken() -> String {
SymmetricKey(size: .bits256).withUnsafeBytes { raw in
raw.map { String(format: "%02x", $0) }.joined()
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Vapor
/// Donations, by hand-off rather than by embedding.
///
/// 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:
///
/// 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.
struct DonateRoutes: Sendable {
let config: Config
/// Whole dollars. An upper bound because a typo in an amount field is far
/// more likely than a five-figure donation, and the refund is somebody's
/// afternoon.
static let minAmount = 1
static let maxAmount = 10_000
struct Form: Content {
let frequency: String?
let amount: String?
let custom_amount: String?
}
func checkout(_ request: Request) async throws -> Response {
guard let provider = config.donateProvider, let secret = config.donateSecret else {
throw WireError("donations_disabled",
"This relay has no payment processor configured.", 503)
}
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.
let raw = (form.amount == "custom" ? form.custom_amount : form.amount) ?? ""
guard let dollars = Int(raw.trimmingCharacters(in: .whitespaces)),
dollars >= Self.minAmount, dollars <= Self.maxAmount
else {
throw WireError.badRequest(
"Enter an amount between $\(Self.minAmount) and $\(Self.maxAmount).")
}
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)
}
}
/// 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
) async throws -> String {
let base = "\(request.headers.first(name: .init("X-Forwarded-Proto")) ?? "https")://"
+ (request.headers.first(name: .host) ?? "localhost")
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" }
let body = fields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? k)="
+ (v.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? v)
}.joined(separator: "&")
var headers = HTTPHeaders()
headers.add(name: .authorization, value: "Bearer \(secret)")
headers.add(name: .contentType, value: "application/x-www-form-urlencoded")
let response = try await request.client.post(
"https://api.stripe.com/v1/checkout/sessions",
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
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)
}
return url
}
}
+191
View File
@@ -0,0 +1,191 @@
import Fluent
import Foundation
import Vapor
/// §8.9 — the group registry.
///
/// Plaintext metadata only. The relay never relays a group message differently
/// from a 1:1 message and cannot tell them apart: senders fan out to the roster
/// themselves, once per recipient device, and receivers attribute by the
/// envelope's `from_user`, which the ratchet already authenticates.
///
/// So what this is, plainly: the one place the relay does learn who talks to
/// whom in a group. The port does not change that and should not be read as
/// having fixed it.
extension Routes {
// MARK: - POST /e2e/groups
func createGroup(_ request: Request, body: Data, now: Int) async throws -> Response {
let caller = try await authenticate(request, body: body, path: "/e2e/groups", now: now)
let input = try decodeBody(CreateGroupRequest.self, from: body)
let name = input.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= Group.maxNameLength else {
throw WireError.badRequest(
"Group name required (at most \(Group.maxNameLength) characters).")
}
// The creator is always a member and the owner, and is filtered out of
// the supplied list so that naming yourself is not an error.
let members = Set(input.members ?? [])
.filter { $0 > 0 && $0 != caller.userID }
.sorted()
guard members.count + 1 <= Group.maxMembers else {
throw WireError.badRequest("Groups are capped at \(Group.maxMembers) members.")
}
try await requireReachable(members, on: request.db)
let gid = randomHexToken()
try await request.db.transaction { db in
let group = Group()
group.id = gid
group.name = name
group.ownerUserID = caller.userID
try await group.create(on: db)
for uid in [caller.userID] + members {
let m = GroupMember()
m.gid = gid; m.userID = uid
try await m.create(on: db)
}
}
var payload = try await groupPayload(gid, on: request.db)
payload?.ok = true
guard let payload else { throw WireError.notFound("No such group.") }
return try .json(payload)
}
// MARK: - GET /e2e/groups
func listGroups(_ request: Request, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/groups", now: now)
let mine = try await GroupMember.query(on: request.db)
.filter(\.$userID == caller.userID).all().map(\.gid)
var out: [GroupResponse] = []
for gid in Set(mine).sorted() {
if let payload = try await groupPayload(gid, on: request.db) { out.append(payload) }
}
return try .json(GroupListResponse(groups: out))
}
// MARK: - POST /e2e/groups/{gid}/members
func updateGroupMembers(
_ request: Request, gid: String, body: Data, now: Int
) async throws -> Response {
let caller = try await authenticate(
request, body: body, path: "/e2e/groups/\(gid)/members", now: now)
guard let group = try await Group.find(gid, on: request.db) else {
throw WireError.notFound("No such group.")
}
guard group.ownerUserID == caller.userID else {
throw WireError.forbidden("Only the group owner can change members.")
}
let input = try decodeBody(UpdateMembersRequest.self, from: body)
let remove = Set((input.remove ?? []).filter { $0 > 0 })
let add = Set((input.add ?? []).filter { $0 > 0 })
// The owner leaves through /leave, which handles succession. Removing
// themselves here would orphan the group silently.
guard !remove.contains(caller.userID) else {
throw WireError.badRequest("Use leave to remove yourself.")
}
let current = Set(try await GroupMember.query(on: request.db)
.filter(\.$gid == gid).all().map(\.userID))
let adding = add.subtracting(current)
try await requireReachable(Array(adding), on: request.db)
let remaining = current.subtracting(remove).union(adding)
guard remaining.count <= Group.maxMembers else {
throw WireError.badRequest("Groups are capped at \(Group.maxMembers) members.")
}
try await request.db.transaction { db in
for uid in adding.sorted() {
let m = GroupMember()
m.gid = gid; m.userID = uid
try await m.create(on: db)
}
if !remove.isEmpty {
try await GroupMember.query(on: db)
.filter(\.$gid == gid)
.filter(\.$userID ~~ Array(remove))
.delete()
}
}
guard let payload = try await groupPayload(gid, on: request.db) else {
throw WireError.notFound("No such group.")
}
return try .json(payload)
}
// MARK: - POST /e2e/groups/{gid}/leave
func leaveGroup(_ request: Request, gid: String, body: Data, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: body, path: "/e2e/groups/\(gid)/leave", now: now)
guard let group = try await Group.find(gid, on: request.db) else {
throw WireError.notFound("No such group.")
}
let members = try await GroupMember.query(on: request.db)
.filter(\.$gid == gid).sort(\.$id, .ascending).all()
guard members.contains(where: { $0.userID == caller.userID }) else {
throw WireError.badRequest("Not a member.")
}
try await request.db.transaction { db in
try await GroupMember.query(on: db)
.filter(\.$gid == gid).filter(\.$userID == caller.userID).delete()
let remaining = members.filter { $0.userID != caller.userID }
if remaining.isEmpty {
// The last member out deletes the group rather than leaving an
// ownerless row that nothing can ever address again.
try await group.delete(on: db)
} else if group.ownerUserID == caller.userID {
// Ownership passes to the longest-standing remaining member.
group.ownerUserID = remaining[0].userID
try await group.update(on: db)
}
}
return try .json(OKResponse(ok: true))
}
// MARK: - Helpers
private func groupPayload(_ gid: String, on db: any Database) async throws -> GroupResponse? {
guard let group = try await Group.find(gid, on: db) else { return nil }
let members = try await GroupMember.query(on: db)
.filter(\.$gid == gid).sort(\.$id, .ascending).all()
return GroupResponse(
ok: nil,
gid: gid,
name: group.name,
owner: group.ownerUserID,
members: members.map { .init(user_id: $0.userID) })
}
/// The relay cannot answer "is this a real account" — that is the identity
/// provider's question, and the PHP version could only answer it because it
/// *was* the identity provider.
///
/// What it can check is whether a member is reachable at all: a user with no
/// registered device cannot receive a single group envelope, because fan-out
/// is per device. So that is the check, and it is a stricter one than
/// `get_userdata()` was — it rejects a typo'd id, and it also rejects a real
/// account that has never set up encryption, which for an encrypted group is
/// the honest answer rather than a silent black hole.
private func requireReachable(_ userIDs: [Int], on db: any Database) async throws {
guard !userIDs.isEmpty else { return }
let reachable = Set(try await Device.query(on: db)
.filter(\.$userID ~~ userIDs).all().map(\.userID))
for uid in userIDs where !reachable.contains(uid) {
throw WireError.badRequest("No reachable device for user: \(uid).")
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import Crypto
import Fluent
import Foundation
import Vapor
/// §19 — key transparency.
///
/// These routes are unauthenticated on purpose. A transparency log that only
/// its own users can read is not transparency: the value comes from anyone —
/// a researcher, a monitor, the other party to a conversation — being able to
/// fetch a head and check it. They expose commitments to public keys, which
/// were already public.
extension Routes {
/// The signed head. Everything else is checked against this.
///
/// Signed with the issuer key, which is what makes a fork *evidence*: two
/// heads of the same size with different roots, both carrying a valid
/// signature, are a statement the relay cannot retract or blame on a
/// client. Unsigned, a fork would be one party's word against another's.
func logHead(_ request: Request) async throws -> Response {
let leaves = try await KeyLog.leaves(on: request.db)
let root = MerkleLog.root(leaves)
let size = leaves.count
let signed = Data("fnkt-v1|\(size)|\(root.base64EncodedString())".utf8)
// A relay running without a signing key (verification only, issuance
// delegated elsewhere) can still serve the log — it just cannot make
// its head into evidence. Saying so beats signing with nothing.
guard let seed = config.issuerSigningKeyRaw,
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: seed),
let signature = try? key.signature(for: signed)
else {
throw WireError("log_unsigned",
"This relay has no signing key, so its log head is unsigned.", 503)
}
return try .json(LogHeadResponse(
size: size,
root: root.base64EncodedString(),
signature: signature.base64EncodedString(),
audience: config.audience))
}
/// Proof that the leaf at `index` is in the tree of size `size`.
func logInclusion(_ request: Request) async throws -> Response {
let leaves = try await KeyLog.leaves(on: request.db)
guard let index = request.query[Int.self, at: "index"] else {
throw WireError.badRequest("index is required.")
}
// Defaults to the current head, but an older size is accepted so a
// client can verify against a head it already holds rather than one it
// is being handed now — which is the whole point of pinning a head.
let size = request.query[Int.self, at: "size"] ?? leaves.count
guard size > 0, size <= leaves.count else {
throw WireError.badRequest("size is beyond the log.")
}
let view = Array(leaves[..<size])
guard index >= 0, index < size,
let proof = MerkleLog.inclusionProof(view, index: index)
else { throw WireError.badRequest("index is beyond that tree.") }
return try .json(LogInclusionResponse(
index: index,
size: size,
leaf: view[index].base64EncodedString(),
root: MerkleLog.root(view).base64EncodedString(),
proof: proof.map { $0.base64EncodedString() }))
}
/// Proof that the tree of size `from` is a prefix of the tree of size `to`.
///
/// This is the half that makes the log append-only. Inclusion proofs alone
/// would still verify against a log whose history had been rewritten.
func logConsistency(_ request: Request) async throws -> Response {
let leaves = try await KeyLog.leaves(on: request.db)
guard let from = request.query[Int.self, at: "from"] else {
throw WireError.badRequest("from is required.")
}
let to = request.query[Int.self, at: "to"] ?? leaves.count
guard from > 0, from <= to, to <= leaves.count else {
throw WireError.badRequest("from and to must satisfy 0 < from <= to <= size.")
}
guard let proof = MerkleLog.consistencyProof(leaves, from: from, to: to) else {
throw WireError.badRequest("no such consistency proof.")
}
return try .json(LogConsistencyResponse(
from: from,
to: to,
from_root: MerkleLog.root(Array(leaves[..<from])).base64EncodedString(),
to_root: MerkleLog.root(Array(leaves[..<to])).base64EncodedString(),
proof: proof.map { $0.base64EncodedString() }))
}
/// Where a device's entries are, so a client can find its own key without
/// scanning the log.
///
/// Returns every entry for the device, not just the newest: a client that
/// has been shown a key it did not publish needs to see that there is an
/// entry it does not recognise, which a "current key" answer would hide.
func logDevice(_ request: Request, deviceID: String) async throws -> Response {
guard isValidDeviceID(deviceID) else {
throw WireError.badRequest("Malformed device id.")
}
let rows = try await LogEntry.query(on: request.db)
.filter(\.$deviceID == deviceID).sort(\.$id, .ascending).all()
let size = try await LogEntry.query(on: request.db).count()
return try .json(LogDeviceResponse(
device_id: deviceID,
size: size,
entries: rows.map {
.init(index: ($0.id ?? 1) - 1, kind: $0.kind, leaf: $0.leafHash)
}))
}
}
+301
View File
@@ -0,0 +1,301 @@
import Crypto
import Fluent
import Foundation
import Vapor
/// `/messenger/*` — buddy list, presence, and the non-E2E message path.
///
/// Ported from `FNMSG_DB` and `FNMSG_Inbox`, with four things the plugin did
/// not have. Each is here because leaving it out would mean porting a known gap
/// forward:
///
/// 1. **Retention.** The plugin kept every plaintext-path message forever. The
/// E2E queue purges in a day; this table grew without bound, holding both
/// content and a complete who-talked-to-whom-and-when record.
/// 2. **Disappearing messages**, per conversation. Device access, not broken
/// cryptography, is what actually compromises a messenger like this.
/// 3. **First-contact gating.** A message from a stranger is a request, not an
/// inbox item.
/// 4. **Presence scoped to the buddy list.** Presence was world-readable, which
/// makes it an activity feed on any member for anyone who asks.
struct MessagingRoutes: Sendable {
let auth: Routes
let atRestKey: AtRestKey
let limits: RateLimiter
/// How stale a heartbeat may be before someone reads as offline.
static let onlineWindow: TimeInterval = 300
// MARK: - Buddies
func listBuddies(_ request: Request, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/buddies", now: now)
let rows = try await Buddy.query(on: request.db)
.filter(\.$userID == me.userID).sort(\.$id, .ascending).all()
var out: [BuddyView] = []
for row in rows {
out.append(.init(
user_id: row.buddyID,
display_name: try await displayName(row.buddyID, on: request.db),
status: try await effectiveStatus(
of: row.buddyID, viewedBy: me.userID, on: request.db).rawValue))
}
return try .json(BuddyListResponse(buddies: out))
}
func addBuddy(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/buddies", now: now)
let input = try auth.decodeBody(BuddyRequest.self, from: body)
guard input.buddy_id > 0, input.buddy_id != me.userID else {
throw WireError.badRequest("A buddy id is required, and it cannot be you.")
}
guard try await User.find(input.buddy_id, on: request.db) != nil else {
throw WireError.notFound("No such user.")
}
// Adding is one-way and idempotent. Mutual visibility is two rows —
// following was never symmetric here.
if try await Buddy.query(on: request.db)
.filter(\.$userID == me.userID).filter(\.$buddyID == input.buddy_id).first() == nil
{
let b = Buddy()
b.userID = me.userID; b.buddyID = input.buddy_id
try await b.create(on: request.db)
}
return try .json(OKResponse(ok: true))
}
func removeBuddy(_ request: Request, buddyID: Int, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/buddies/\(buddyID)", now: now)
try await Buddy.query(on: request.db)
.filter(\.$userID == me.userID).filter(\.$buddyID == buddyID).delete()
return try .json(OKResponse(ok: true))
}
// MARK: - Presence
func setPresence(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/presence", now: now)
let input = try auth.decodeBody(PresenceRequest.self, from: body)
guard let chosen = ChosenStatus(rawValue: input.status) else {
throw WireError.badRequest("Status must be online, away or invisible.")
}
guard let user = try await User.find(me.userID, on: request.db) else {
throw WireError.notFound("No such user.")
}
user.chosenStatus = chosen.rawValue
user.lastSeenAt = Self.coarsen(Date())
try await user.update(on: request.db)
return try .json(OKResponse(ok: true))
}
func getPresence(_ request: Request, userID: Int, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/presence/\(userID)", now: now)
let status = try await effectiveStatus(of: userID, viewedBy: me.userID, on: request.db)
return try .json(PresenceResponse(user_id: userID, status: status.rawValue))
}
/// Presence as `viewer` is allowed to see it.
///
/// Only someone on the subject's own buddy list gets a real answer;
/// everyone else reads offline. Presence is an activity feed — when you
/// woke up, when you went to bed — and world-readable presence hands that
/// to anyone who can name a user id. Signal's answer is to have no presence
/// at all; this product wants away messages and idle status, so the answer
/// here is to scope it instead of to drop it.
///
/// This is the server half. It is not the same as presence *encrypted* to
/// the buddy list, which would put it beyond the operator too — worth
/// doing, and a protocol change rather than a query.
private func effectiveStatus(
of subject: Int, viewedBy viewer: Int, on db: any Database
) async throws -> EffectiveStatus {
guard let user = try await User.find(subject, on: db) else { return .offline }
if subject != viewer {
let visible = try await Buddy.query(on: db)
.filter(\.$userID == subject).filter(\.$buddyID == viewer).first() != nil
guard visible else { return .offline }
}
// Invisible reads as offline to everyone, which is the entire point of
// choosing it — including here, before any freshness check.
guard let chosen = ChosenStatus(rawValue: user.chosenStatus), chosen != .invisible else {
return .offline
}
guard let seen = user.lastSeenAt,
Date().timeIntervalSince(seen) < Self.onlineWindow
else { return .offline }
return chosen == .away ? .away : .online
}
/// Round a heartbeat down to the online window.
///
/// The only question ever asked of this value is "within the last five
/// minutes?", so second precision buys nothing and stores a fine-grained
/// activity log. Bucketing gives an identical answer from a coarser record.
static func coarsen(_ date: Date) -> Date {
let w = onlineWindow
return Date(timeIntervalSince1970: (date.timeIntervalSince1970 / w).rounded(.down) * w)
}
// MARK: - Messages
func send(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/messages", now: now)
let input = try auth.decodeBody(SendMessageRequest.self, from: body)
let text = input.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw WireError.badRequest("Message body required.") }
guard text.count <= 8000 else { throw WireError.badRequest("Message is too long.") }
guard input.to > 0, input.to != me.userID,
try await User.find(input.to, on: request.db) != nil
else { throw WireError.notFound("No such user.") }
guard await limits.consume("msg:\(me.userID)", limit: 600, now: now) else {
throw WireError.rateLimited("Too many messages sent recently.")
}
// The same single check the mobile path asks. Two send routes, one
// answer to "may this person write to that one".
guard try await !Block.blocks(input.to, me.userID, on: request.db) else {
throw WireError.forbidden("That member is not accepting messages from you.")
}
// The recipient's own view decides the lifetime: a disappearing
// conversation is a property of the conversation, and the sender does
// not get to override how long their words persist on someone else's
// device.
let theirState = try await state(for: input.to, other: me.userID, on: request.db)
let m = Message()
m.fromID = me.userID
m.toID = input.to
m.body = try AtRestCrypto.seal(text, using: atRestKey.key)
m.isRead = false
if let ttl = theirState.disappearAfter, ttl > 0 {
m.expiresAt = Date().addingTimeInterval(TimeInterval(ttl))
}
try await m.create(on: request.db)
return try .json(SendMessageResponse(
ok: true, id: m.id ?? 0,
// Told plainly, because a sender should know whether they are
// writing to an inbox or to a request that may never be opened.
pending: try await isRequest(from: me.userID, to: input.to, on: request.db)))
}
func thread(_ request: Request, with other: Int, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/thread/\(other)", now: now)
let mine = try await state(for: me.userID, other: other, on: request.db)
let after = max(request.query[Int.self, at: "after"] ?? 0, mine.clearedToID)
let limit = min(request.query[Int.self, at: "limit"] ?? 100, 200)
let rows = try await Message.query(on: request.db)
.group(.or) { pair in
pair.group(.and) { $0.filter(\.$fromID == me.userID).filter(\.$toID == other) }
pair.group(.and) { $0.filter(\.$fromID == other).filter(\.$toID == me.userID) }
}
.filter(\.$id > after)
.sort(\.$id, .ascending)
.limit(limit)
.all()
// Expiry is enforced on read as well as by the sweep. The sweep runs
// hourly; a message whose moment has passed must not be readable in
// the meantime just because the timer has not fired.
let live = rows.filter { ($0.expiresAt.map { $0 > Date() }) ?? true }
return try .json(ThreadResponse(
with: other,
messages: live.map {
.init(id: $0.id ?? 0, from: $0.fromID, to: $0.toID,
body: AtRestCrypto.open($0.body, using: atRestKey.key),
sent_at: iso8601($0.sentAt),
expires_at: $0.expiresAt.map(iso8601))
}))
}
func markRead(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/read", now: now)
let input = try auth.decodeBody(BuddyRequest.self, from: body)
try await Message.query(on: request.db)
.filter(\.$toID == me.userID).filter(\.$fromID == input.buddy_id)
.set(\.$isRead, to: true).update()
return try .json(OKResponse(ok: true))
}
func unread(_ request: Request, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/unread", now: now)
let total = try await Message.query(on: request.db)
.filter(\.$toID == me.userID).filter(\.$isRead == false).count()
return try .json(UnreadResponse(unread: total))
}
// MARK: - Conversation settings
func setConversation(_ request: Request, other: Int, body: Data, now: Int) async throws
-> Response
{
let me = try await auth.authenticate(
request, body: body, path: "/messenger/conversation/\(other)", now: now)
let input = try auth.decodeBody(ConversationRequest.self, from: body)
let row = try await state(for: me.userID, other: other, on: request.db)
if let starred = input.starred { row.starred = starred }
if let archived = input.archived { row.archived = archived }
if let mode = input.enc_mode {
guard ["", "enc", "plain"].contains(mode) else {
throw WireError.badRequest("enc_mode must be enc or plain.")
}
row.encMode = mode
}
if let accept = input.accept, accept { row.acceptedAt = row.acceptedAt ?? Date() }
if let ttl = input.disappear_after {
// 0 means off. A negative or absurd value is a client bug, and a
// lifetime longer than retention would be a promise the sweep
// breaks.
guard ttl >= 0, ttl <= 30 * 24 * 3600 else {
throw WireError.badRequest("disappear_after must be 0–2592000 seconds.")
}
row.disappearAfter = ttl == 0 ? nil : ttl
}
try await row.save(on: request.db)
return try .json(OKResponse(ok: true))
}
// MARK: - Helpers
private func state(for user: Int, other: Int, on db: any Database) async throws
-> ConversationState
{
if let existing = try await ConversationState.query(on: db)
.filter(\.$userID == user).filter(\.$otherID == other).first()
{ return existing }
let row = ConversationState()
row.userID = user; row.otherID = other
row.starred = false; row.archived = false
row.encMode = ""; row.clearedToID = 0
return row
}
/// A first contact from someone the recipient neither added nor accepted.
private func isRequest(from sender: Int, to recipient: Int, on db: any Database) async throws
-> Bool
{
if try await Buddy.query(on: db)
.filter(\.$userID == recipient).filter(\.$buddyID == sender).first() != nil
{ return false }
let theirs = try await ConversationState.query(on: db)
.filter(\.$userID == recipient).filter(\.$otherID == sender).first()
return theirs?.acceptedAt == nil
}
private func displayName(_ userID: Int, on db: any Database) async throws -> String {
(try await User.find(userID, on: db))?.displayName ?? "Member #\(userID)"
}
}
+313
View File
@@ -0,0 +1,313 @@
import Crypto
import Fluent
import Foundation
import Vapor
/// `/api/flamenet/v1/messenger/*` — the surface the shipped iOS app already
/// speaks.
///
/// This is a **compatibility** layer, and it is deliberately not the same shape
/// as the `/messenger/*` routes beside it. Those were designed here; these are
/// dictated by a client that is already written. Porting the server without
/// this means the app cannot reach it at all, however good the new routes are.
///
/// ## Auth is weaker here, on purpose, and it is worth naming
///
/// The app sends HTTP Basic with its app credential — a bearer credential. The
/// `/e2e/*` routes require a device-scoped token plus a per-request signature,
/// so a stolen token is inert; a stolen Basic credential is not.
///
/// 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
/// 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.
struct MobileRoutes: Sendable {
let accounts: AccountRoutes
let atRestKey: AtRestKey
let limits: RateLimiter
/// Resolve the Basic credential to a user, or refuse.
/// Internal rather than private: the extras live in a second file.
func caller(_ request: Request) async throws -> Int {
guard let basic = request.headers.basicAuthorization,
let userID = try await accounts.verifyAppCredential(basic.password, on: request.db)
else { throw WireError.unauthorized("Valid credential required.") }
// Any authenticated call is a sign of life. Coarsened, as everywhere.
if let user = try await User.find(userID, on: request.db) {
let beat = MessagingRoutes.coarsen(Date())
if user.lastSeenAt != beat {
user.lastSeenAt = beat
try? await user.update(on: request.db)
}
}
return userID
}
// MARK: - GET /messenger/buddies
func buddies(_ request: Request) async throws -> Response {
let me = try await caller(request)
let rows = try await Buddy.query(on: request.db)
.filter(\.$userID == me).sort(\.$id, .ascending).all()
var out: [MobileBuddyList.Row] = []
for row in rows {
let unread = try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == row.buddyID)
.filter(\.$isRead == false).count()
let buddyUser = try await User.find(row.buddyID, on: request.db)
out.append(.init(
id: row.buddyID,
display_name: buddyUser?.displayName ?? "User #\(row.buddyID)",
// The account's username. This field started life as the OSCAR
// screen name and was left null when that bridge was not
// ported -- but the client reads it as the @handle, and used it
// as the username when adding somebody. Null meant you could
// not find your own buddies by the name you know them by, only
// by whatever display name they had set.
screen_name: buddyUser?.username,
presence: try await visibleStatus(
of: row.buddyID, to: me, on: request.db).rawValue,
unread: unread))
}
return try .json(MobileBuddyList(buddies: out))
}
// MARK: - POST /messenger/buddies
/// The app adds by **username**, not id — it never learns ids until the
/// list comes back.
func addBuddy(_ request: Request) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(MobileAddBuddyRequest.self)
let username = User.normalize(input.username)
guard let target = try await User.query(on: request.db)
.filter(\.$username == username).first(), let targetID = target.id
else { throw WireError.notFound("No member with that username.") }
guard targetID != me else {
throw WireError.badRequest("You cannot add yourself.")
}
if try await Buddy.query(on: request.db)
.filter(\.$userID == me).filter(\.$buddyID == targetID).first() == nil
{
let b = Buddy(); b.userID = me; b.buddyID = targetID
try await b.create(on: request.db)
}
return try .json(MobileMessageOnly(message: "Added \(target.displayName)."))
}
func removeBuddy(_ request: Request, buddyID: Int) async throws -> Response {
let me = try await caller(request)
try await Buddy.query(on: request.db)
.filter(\.$userID == me).filter(\.$buddyID == buddyID).delete()
return try .json(MobileMessageOnly(message: "Removed."))
}
// MARK: - POST /messenger/messages
func send(_ request: Request, now: Int) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(SendMessageRequest.self)
let text = input.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, text.count <= 8000 else {
throw WireError.badRequest("Message body required.")
}
guard input.to != me, try await User.find(input.to, on: request.db) != nil else {
throw WireError.notFound("No such user.")
}
guard await limits.consume("msg:\(me)", limit: 600, now: now) else {
throw WireError.rateLimited("Too many messages sent recently.")
}
// One check, asked by every send path. The plugin routed both through a
// shared helper precisely so the block list and the send-path check
// could not drift apart, and that is worth keeping.
guard try await !Block.blocks(input.to, me, on: request.db) else {
throw WireError.forbidden("That member is not accepting messages from you.")
}
// Same rules as the designed route: the recipient's conversation
// decides the lifetime. Two entry points, one policy.
let theirTTL = try await ConversationState.query(on: request.db)
.filter(\.$userID == input.to).filter(\.$otherID == me).first()?.disappearAfter
let m = Message()
m.fromID = me; m.toID = input.to
m.body = try AtRestCrypto.seal(text, using: atRestKey.key)
m.isRead = false
if let ttl = theirTTL, ttl > 0 {
m.expiresAt = Date().addingTimeInterval(TimeInterval(ttl))
}
try await m.create(on: request.db)
let mine = try await User.find(me, on: request.db)
// A stranger's first message raises a notification rather than landing
// silently; collapsed, so forty messages do not make forty entries.
if try await isFirstContact(from: me, to: input.to, on: request.db) {
try await Notification.raise(
"message_request", for: input.to,
message: "\(mine?.displayName ?? "Someone") wants to message you.",
on: request.db)
}
return try .json(MobileSendResponse(
id: m.id ?? 0,
who: mine?.displayName ?? "Me",
body: text,
time: shortTime(m.sentAt ?? Date())))
}
// MARK: - GET /messenger/messages?buddy=&after=
func poll(_ request: Request) async throws -> Response {
let me = try await caller(request)
guard let buddy = request.query[Int.self, at: "buddy"] else {
throw WireError.badRequest("buddy is required.")
}
let after = request.query[Int.self, at: "after"] ?? 0
// Inbound only. The client already holds what it sent, and echoing it
// back would render every outgoing message twice.
let rows = try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == buddy).filter(\.$id > after)
.sort(\.$id, .ascending).limit(200).all()
let live = rows.filter { ($0.expiresAt.map { $0 > Date() }) ?? true }
if !live.isEmpty {
try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == buddy)
.set(\.$isRead, to: true).update()
}
let name = (try await User.find(buddy, on: request.db))?.displayName ?? "User #\(buddy)"
return try .json(MobilePollResponse(
messages: live.map {
.init(id: $0.id ?? 0, who: name,
body: AtRestCrypto.open($0.body, using: atRestKey.key),
time: shortTime($0.sentAt ?? Date()))
},
status: try await visibleStatus(of: buddy, to: me, on: request.db).rawValue))
}
// MARK: - POST /messenger/status
func status(_ request: Request) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(MobileStatusRequest.self)
guard let user = try await User.find(me, on: request.db) else {
throw WireError.notFound("No such user.")
}
if let raw = input.status, let chosen = ChosenStatus(rawValue: raw) {
user.chosenStatus = chosen.rawValue
user.lastSeenAt = MessagingRoutes.coarsen(Date())
try await user.update(on: request.db)
}
// The away *message* is not stored. It would be a server-readable
// string about the user's whereabouts attached to their account, which
// is precisely the metadata this port has been removing rather than
// adding. Echoed back so the client renders what it sent; when away
// messages return they should be encrypted to the buddy list.
return try .json(MobileStatusResponse(
status: user.chosenStatus, message: input.message ?? ""))
}
// MARK: - POST /messenger/conversation
func conversation(_ request: Request) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(MobileConversationRequest.self)
let row = try await ConversationState.query(on: request.db)
.filter(\.$userID == me).filter(\.$otherID == input.buddy).first()
?? {
let fresh = ConversationState()
fresh.userID = me; fresh.otherID = input.buddy
fresh.starred = false; fresh.archived = false
fresh.encMode = ""; fresh.clearedToID = 0
return fresh
}()
switch input.action {
case "star": row.starred = true
case "unstar": row.starred = false
case "archive": row.archived = true
case "unarchive": row.archived = false
case "clear":
// Personal to this viewer: the other side keeps their copy, which
// is what the plugin did and what the word means here.
let newest = try await Message.query(on: request.db)
.group(.or) { g in
g.group(.and) { $0.filter(\.$fromID == me).filter(\.$toID == input.buddy) }
g.group(.and) { $0.filter(\.$fromID == input.buddy).filter(\.$toID == me) }
}
.sort(\.$id, .descending).first()
row.clearedToID = newest?.id ?? row.clearedToID
case "disappear":
// The relay has had per-conversation lifetimes since the port, but
// this surface had no way to set one — so the feature existed and
// no client could reach it. Same bounds as the designed route: a
// lifetime longer than retention would be a promise the sweep
// cannot keep.
guard let seconds = input.seconds, seconds >= 0, seconds <= 30 * 24 * 3600 else {
throw WireError.badRequest("seconds must be 0–2592000.")
}
row.disappearAfter = seconds == 0 ? nil : seconds
default:
throw WireError.badRequest("Unknown action.")
}
try await row.save(on: request.db)
return try .json(MobileMessageOnly(message: "Done."))
}
// MARK: - GET /messenger/tier
func tier(_ request: Request) async throws -> Response {
_ = try await caller(request)
// Nothing here gates on a membership tier. Answering with a plain
// "Member" is honest and keeps the client's copy rendering rather than
// showing "Unknown tier."
return try .json(MobileMessageOnly(message: "Member"))
}
// MARK: - Helpers
private func visibleStatus(
of subject: Int, to viewer: Int, on db: any Database
) async throws -> EffectiveStatus {
guard let user = try await User.find(subject, on: db) else { return .offline }
if subject != viewer {
let visible = try await Buddy.query(on: db)
.filter(\.$userID == subject).filter(\.$buddyID == viewer).first() != nil
guard visible else { return .offline }
}
guard let chosen = ChosenStatus(rawValue: user.chosenStatus), chosen != .invisible,
let seen = user.lastSeenAt,
Date().timeIntervalSince(seen) < MessagingRoutes.onlineWindow
else { return .offline }
return chosen == .away ? .away : .online
}
/// A first contact from someone the recipient neither added nor accepted.
private func isFirstContact(from sender: Int, to recipient: Int, on db: any Database)
async throws -> Bool
{
if try await Buddy.query(on: db)
.filter(\.$userID == recipient).filter(\.$buddyID == sender).first() != nil
{ return false }
let theirs = try await ConversationState.query(on: db)
.filter(\.$userID == recipient).filter(\.$otherID == sender).first()
return theirs?.acceptedAt == nil
}
/// `3:12 pm` — the client renders this verbatim, so the format is part of
/// the contract rather than a presentation choice made here.
private func shortTime(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "h:mm a"
f.locale = Locale(identifier: "en_US_POSIX")
f.amSymbol = "am"; f.pmSymbol = "pm"
return f.string(from: date)
}
}
@@ -0,0 +1,190 @@
import Crypto
import Fluent
import Foundation
import Vapor
/// The four routes the app calls that had nothing behind them: services,
/// notifications, blocks, and commands.
extension MobileRoutes {
// MARK: - GET /messenger/services
/// AIM and ICQ, as this server can honestly report them.
///
/// The OSCAR bridge is not ported, so nothing here can connect to either
/// network. Reporting `connected: false` is the truthful answer and the
/// client already renders it as "not connected".
func services(_ request: Request) async throws -> Response {
_ = try await caller(request)
return try .json(ServicesStatusResponse(
aim: .init(connected: false, screen_name: nil),
icq: .init(connected: false, screen_name: nil)))
}
/// Connecting is refused rather than accepted-and-ignored.
///
/// The request body carries the user's **AIM or ICQ password**. Storing a
/// third-party credential for a bridge that does not exist would be
/// collecting a secret this server has no use for and no way to spend —
/// the worst possible trade, since the breach risk is real and the benefit
/// is zero. 503 says the feature is absent, which is different from saying
/// the credentials were wrong.
func connectService(_ request: Request, network: String) async throws -> Response {
_ = try await caller(request)
guard ["aim", "icq"].contains(network) else {
throw WireError.notFound("No such service.")
}
throw WireError(
"unavailable",
"The \(network.uppercased()) bridge is not available on this server.",
503)
}
func disconnectService(_ request: Request, network: String) async throws -> Response {
_ = try await caller(request)
guard ["aim", "icq"].contains(network) else {
throw WireError.notFound("No such service.")
}
// Idempotent: nothing is connected, so disconnecting succeeds.
return try .json(MobileMessageOnly(message: "Disconnected."))
}
// MARK: - GET /messenger/notifications
func notifications(_ request: Request) async throws -> Response {
let me = try await caller(request)
let after = request.query[Int.self, at: "after"] ?? 0
let unreadOnly = (request.query[String.self, at: "unread_only"] ?? "") == "true"
var query = Notification.query(on: request.db).filter(\.$userID == me)
if unreadOnly { query = query.filter(\.$isRead == false) }
if after > 0 { query = query.filter(\.$id > after) }
let rows = try await query.sort(\.$id, .descending).limit(50).all()
let unread = try await Notification.query(on: request.db)
.filter(\.$userID == me).filter(\.$isRead == false).count()
return try .json(NotificationsResponse(
notifications: rows.map {
.init(id: $0.id ?? 0, type: $0.type, message: $0.message,
url: $0.url, is_read: $0.isRead, created_at: iso8601($0.createdAt))
},
unread: unread,
latest_id: rows.map { $0.id ?? 0 }.max() ?? after))
}
func markNotificationRead(_ request: Request, id: Int) async throws -> Response {
let me = try await caller(request)
// Scoped to the caller: without the user filter this marks anyone's
// notification read by guessing an id.
guard let row = try await Notification.query(on: request.db)
.filter(\.$id == id).filter(\.$userID == me).first()
else { throw WireError.notFound("No such notification.") }
row.isRead = true
try await row.update(on: request.db)
return try .json(MobileMessageOnly(message: "Read."))
}
func markAllNotificationsRead(_ request: Request) async throws -> Response {
let me = try await caller(request)
try await Notification.query(on: request.db)
.filter(\.$userID == me).set(\.$isRead, to: true).update()
return try .json(MobileMessageOnly(message: "All read."))
}
// MARK: - Blocks
func blocks(_ request: Request) async throws -> Response {
let me = try await caller(request)
let rows = try await Block.query(on: request.db)
.filter(\.$userID == me).sort(\.$id, .ascending).all()
var out: [BlockListResponse.Row] = []
for row in rows {
guard let u = try await User.find(row.blockedID, on: request.db) else { continue }
out.append(.init(id: row.blockedID, name: u.displayName, slug: u.username))
}
return try .json(BlockListResponse(blocked: out))
}
/// POST blocks, DELETE unblocks — the app toggles by method, as the plugin did.
func toggleBlock(_ request: Request, target: Int, blocking: Bool) async throws -> Response {
let me = try await caller(request)
guard target > 0, target != me else {
throw WireError.badRequest("Invalid user id.")
}
let existing = try await Block.query(on: request.db)
.filter(\.$userID == me).filter(\.$blockedID == target).first()
if blocking, existing == nil {
let b = Block(); b.userID = me; b.blockedID = target
try await b.create(on: request.db)
} else if !blocking, let existing {
try await existing.delete(on: request.db)
}
let count = try await Block.query(on: request.db).filter(\.$userID == me).count()
return try .json(BlockToggleResponse(blocked: blocking, user_id: target, count: count))
}
// MARK: - POST /messenger/command
/// The commands this server can answer on its own.
///
/// The plugin's command set reached into search, files, groups and the
/// wider portal — none of which exists here. Rather than pretend, this
/// answers the ones that are purely about the messenger, and refuses the
/// rest with the same `unknown_command` the client already handles.
func command(_ request: Request) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(CommandRequest.self)
let name = input.name.lowercased().trimmingCharacters(in: .whitespaces)
let arg = (input.arg ?? "").trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { throw WireError.badRequest("Which command?") }
guard let user = try await User.find(me, on: request.db) else {
throw WireError.notFound("No such user.")
}
switch name {
case "away", "back":
user.chosenStatus = (name == "away" ? ChosenStatus.away : .online).rawValue
user.lastSeenAt = MessagingRoutes.coarsen(Date())
try await user.update(on: request.db)
// The away text is accepted and not stored — same reasoning as
// POST /status. It is a server-readable note about where somebody
// is, attached to their account.
return try .json(CommandResponse(
name: name,
message: name == "away" ? "You are away." : "You are back."))
case "invisible":
user.chosenStatus = ChosenStatus.invisible.rawValue
try await user.update(on: request.db)
return try .json(CommandResponse(name: name, message: "You are invisible."))
case "block", "unblock":
let targetName = User.normalize(arg)
guard let target = try await User.query(on: request.db)
.filter(\.$username == targetName).first(), let targetID = target.id,
targetID != me
else { throw WireError.badRequest("Who? Give a username.") }
_ = try await toggleBlock(request, target: targetID, blocking: name == "block")
return try .json(CommandResponse(
name: name,
message: name == "block"
? "Blocked \(target.displayName)."
: "Unblocked \(target.displayName)."))
case "help":
return try .json(CommandResponse(
name: name,
message: "Commands: /away, /back, /invisible, /block <user>, "
+ "/unblock <user>, /help"))
default:
// Matches the plugin's contract so the client's existing error copy
// still applies.
throw WireError("unknown_command", "Unknown command.", 400)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import Crypto
import Foundation
import Vapor
/// §8.10 — TURN relay credentials.
///
/// The standard `coturn` REST-secret scheme: the username is an expiry and a
/// user id, and the credential is an HMAC of it under a secret shared with the
/// TURN server. The relay mints; it never proxies media.
///
/// The TURN server sees DTLS-SRTP ciphertext between peers that authenticated
/// each other through the ratchet (§6.8) — it is a relay in the network sense
/// and learns nothing about the call.
extension Routes {
func turnCredentials(_ request: Request, now: Int) async throws -> Response {
let caller = try await authenticate(
request, body: Data(), path: "/e2e/turn", now: now)
guard let secret = config.turnSecret, let host = config.turnHost else {
// Not configured is a different answer from not permitted, and the
// client shows a different thing for each: "calling is off on this
// server" versus "you can't call".
throw WireError("unavailable", "Calling is not configured on this server.", 503)
}
let username = "\(now + config.turnTTL):\(caller.userID)"
// HMAC-SHA1 because that is what the coturn REST scheme specifies. The
// secret is shared with the TURN server and the tag is a bearer
// credential with a two-hour life; SHA-1's collision weakness is not
// reachable here, but it is worth naming rather than leaving as an
// apparent oversight.
let tag = HMAC<Insecure.SHA1>.authenticationCode(
for: Data(username.utf8), using: SymmetricKey(data: Data(secret.utf8)))
return try .json(TurnResponse(
urls: [
"stun:\(host):3478",
"turn:\(host):3478?transport=udp",
],
username: username,
credential: Data(tag).base64EncodedString(),
ttl: config.turnTTL))
}
}
+198
View File
@@ -0,0 +1,198 @@
import Crypto
import Foundation
// MARK: - Wire shapes
struct TokenHeader: Codable {
let alg: String
let typ: String
}
/// Claims the issuer signs. Every field here exists because a token without it
/// authorises more than it should.
struct TokenClaims: Codable {
/// Issuer id. Informational — the key that verified the signature is what
/// actually decides trust, and that key is pinned in config.
let iss: String
/// The account. Not sufficient on its own: see `dev`.
let sub: Int
/// The single device this token speaks for.
///
/// A token scoped only to a user authorises every route for every one of
/// that user's devices, so one stolen from a browser drains the phone's
/// prekey pool and reads its inbox. Scoping to a device makes the blast
/// radius the device that was actually compromised.
let dev: String
/// The relay this token is valid at.
///
/// Costs nothing while there is one instance and everything the moment
/// there are two: without it, a token minted by any instance is accepted by
/// every other, and federation would inherit that.
let aud: String
let iat: Int
let exp: Int
/// Proof-of-possession key: the device's Ed25519 identity key, base64.
///
/// This is what makes the token not a bearer token. A bearer token in
/// localStorage falls to any XSS in the app's own code — and moving the
/// client to its own origin removes the third-party scripts that were the
/// other XSS source, which promotes the app's own code to the main one.
/// Bound to a key the relay already holds, a stolen token is inert.
let cnf: Confirmation
struct Confirmation: Codable {
let ik_sig: String
}
}
/// The verified caller. Handlers never see a raw token.
struct Caller {
let userID: Int
let deviceID: String
let identityKey: Data
}
enum AuthError: Error, CustomStringConvertible {
case malformed(String)
case badSignature
case expired
case wrongAudience(String)
case missingProof
case badProof
case replayed
case deviceMismatch
var description: String {
switch self {
case .malformed(let why): return "malformed token: \(why)"
case .badSignature: return "token signature does not verify"
case .expired: return "token expired"
case .wrongAudience(let a): return "token audience \(a) is not this relay"
case .missingProof: return "request is missing its proof-of-possession signature"
case .badProof: return "proof-of-possession signature does not verify"
case .replayed: return "proof-of-possession signature already used"
case .deviceMismatch: return "token device does not match the registered identity key"
}
}
}
// MARK: - Replay cache
/// Remembers proof signatures for as long as one could still be inside the
/// clock-skew window.
///
/// Without this, a proof captured once is reusable until its timestamp ages
/// out, which turns proof-of-possession back into a bearer credential with
/// extra steps for the length of the skew window.
actor ReplayCache {
private var seen: [String: Int] = [:]
private let window: Int
init(window: Int) { self.window = window }
/// True if this is the first time we have seen `signature`.
func admit(_ signature: String, now: Int) -> Bool {
if seen.count > 4096 { seen = seen.filter { now - $0.value < window } }
if let at = seen[signature], now - at < window { return false }
seen[signature] = now
return true
}
}
// MARK: - Verifier
/// Verifies tokens against a pinned issuer key, and requests against the
/// device key the token names.
struct TokenVerifier: Sendable {
/// Raw bytes, not a key object — see `Config.issuerKeyRaw` for why.
let issuerKeyRaw: Data
let audience: String
let skew: Int
/// Parse and verify the token itself. Says nothing about the request that
/// carried it — see `verifyProof`.
func verify(token: String, now: Int) throws -> TokenClaims {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3 else { throw AuthError.malformed("expected three segments") }
guard let headerData = base64URLDecode(String(parts[0])),
let claimsData = base64URLDecode(String(parts[1])),
let signature = base64URLDecode(String(parts[2]))
else { throw AuthError.malformed("segment is not base64url") }
let header = try JSONDecoder().decode(TokenHeader.self, from: headerData)
guard header.alg == "EdDSA" else { throw AuthError.malformed("alg \(header.alg)") }
// Signed bytes are the exact received segments, not a re-encoding of
// the decoded claims: re-serialising would let a token whose JSON does
// not round-trip byte-for-byte verify against different content than
// the issuer signed.
let signed = Data("\(parts[0]).\(parts[1])".utf8)
guard let issuerKey = try? Curve25519.Signing.PublicKey(rawRepresentation: issuerKeyRaw),
issuerKey.isValidSignature(signature, for: signed)
else { throw AuthError.badSignature }
let claims = try JSONDecoder().decode(TokenClaims.self, from: claimsData)
guard claims.exp > now, claims.iat <= now + skew else { throw AuthError.expired }
guard claims.aud == audience else { throw AuthError.wrongAudience(claims.aud) }
return claims
}
/// Verify that whoever sent this request holds the private half of the key
/// the token was bound to.
///
/// The signed string commits to method, path, timestamp, a client nonce,
/// and a digest of the body.
///
/// The body digest stops a captured proof being replayed onto different
/// content at the same path. The nonce exists because without it the
/// signature is a pure function of the other four: two polls in the same
/// second, on the same path, with the same (empty) body produce byte-
/// identical proofs, and the replay cache cannot tell an honest repeat from
/// a captured one — so it refuses the honest client. Found by testing, and
/// it would have looked like a flaky relay in production.
func verifyProof(
claims: TokenClaims,
method: String,
path: String,
body: Data,
timestamp: String?,
nonce: String?,
signature: String?,
now: Int
) throws -> Caller {
guard let timestamp, let signature, let nonce, !nonce.isEmpty else {
throw AuthError.missingProof
}
guard let ts = Int(timestamp), abs(now - ts) <= skew else { throw AuthError.badProof }
guard let keyBytes = Data(base64Encoded: claims.cnf.ik_sig),
let deviceKey = try? Curve25519.Signing.PublicKey(rawRepresentation: keyBytes),
let sigBytes = Data(base64Encoded: signature)
else { throw AuthError.badProof }
let digest = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()
let canonical = Data("\(method)\n\(path)\n\(timestamp)\n\(nonce)\n\(digest)".utf8)
guard deviceKey.isValidSignature(sigBytes, for: canonical) else {
throw AuthError.badProof
}
return Caller(userID: claims.sub, deviceID: claims.dev, identityKey: keyBytes)
}
}
// MARK: - base64url
func base64URLDecode(_ s: String) -> Data? {
var t = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while t.count % 4 != 0 { t += "=" }
return Data(base64Encoded: t)
}
func base64URLEncode(_ d: Data) -> String {
d.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
+498
View File
@@ -0,0 +1,498 @@
import Foundation
import Vapor
// MARK: - Errors
/// The error shape the PHP relay returned, kept byte-identical.
///
/// Clients parse `code` to tell a rate limit from a bad request. Changing the
/// shape during a port would be a silent client break on a path only reached
/// when something is already wrong — the worst place to find out.
struct WireError: Error, Content {
let code: String
let message: String
let data: Status
struct Status: Content { let status: Int }
var httpStatus: HTTPResponseStatus { .init(statusCode: data.status) }
init(_ code: String, _ message: String, _ status: Int) {
self.code = code
self.message = message
self.data = Status(status: status)
}
static func unauthorized(_ m: String = "Valid token required.") -> WireError {
WireError("unauthorized", m, 401)
}
static func forbidden(_ m: String) -> WireError { WireError("forbidden", m, 403) }
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) }
}
// MARK: - Requests
struct RegisterDeviceRequest: Decodable {
let device_id: String
let ik_dh: String
let ik_sig: String
let spk_id: Int
let spk: String
let spk_sig: String
let prekeys: [PreKeyIn]?
let proto: Int?
let caps_sig: String?
let pqspk_id: Int?
let pqspk: String?
let pqspk_sig: String?
let pq_prekeys: [PQPreKeyIn]?
/// §20 — ML-DSA-65 identity public key, base64 (1952 bytes raw).
let ik_pq: String?
/// §20 — ML-DSA-65 signature over the same bytes `spk_sig` covers.
let spk_pq_sig: String?
}
struct PreKeyIn: Decodable { let id: Int; let pub: String }
struct PQPreKeyIn: Decodable { let id: Int; let pub: String; let sig: String }
struct ReplenishRequest: Decodable {
let device_id: String
let prekeys: [PreKeyIn]?
let pq_prekeys: [PQPreKeyIn]?
}
struct SendRequest: Decodable {
let to: Int
let messages: [OutgoingEnvelope]
let from_device: String?
/// Presenting this selects sealed mode: the submission is authorised by the
/// recipient's delivery key instead of by a token, and no sender identity is
/// recorded. See `Routes.send`.
let delivery_key: String?
}
struct OutgoingEnvelope: Decodable { let to_device: String; let payload: String }
// MARK: - Responses
struct RegisterDeviceResponse: Content {
let ok: Bool
let device_id: String
let opk_remaining: Int
let pq_opk_remaining: Int?
}
struct BundleResponse: Content {
let user_id: Int
let devices: [Bundle]
struct Bundle: Content {
let device_id: String
let ik_dh: String
let ik_sig: String
let spk_id: Int
let spk: String
let spk_sig: String
let opk: OneTime?
var proto: Int?
var caps_sig: String?
var pqspk_id: Int?
var pqspk: String?
var pqspk_sig: String?
/// §20. Absent on a device that has not published a post-quantum
/// identity — which is most of them, and is why a verifier treats
/// absence as "classical only" rather than as an error.
var ik_pq: String?
var spk_pq_sig: String?
var pqopk: PQOneTime?
}
struct OneTime: Content { let id: Int; let pub: String }
struct PQOneTime: Content { let id: Int; let pub: String; let sig: String }
}
struct DeviceListResponse: Content {
let user_id: Int
let devices: [Identity]
struct Identity: Content {
let device_id: String
let ik_dh: String
let ik_sig: String
let proto: Int
}
}
struct SendResponse: Content { let ok: Bool; let ids: [Int] }
struct PollResponse: Content {
let messages: [Message]
let cursor: Int
struct Message: Content {
let id: Int
let from_user: Int
let from_device: String
let payload: String
let created_at: String
}
}
struct ReplenishResponse: Content {
let ok: Bool
let opk_remaining: Int
let pq_opk_remaining: Int
}
struct RevokeResponse: Content { let ok: Bool; let device_id: String }
// MARK: - JSON plumbing
/// `.withoutEscapingSlashes` so a base64 payload containing `/` is not
/// re-encoded as `\/` — harmless to a JSON parser, but it changes the bytes a
/// client would hash if it ever checksummed a response, and the PHP relay did
/// not escape them either.
let relayJSONEncoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.withoutEscapingSlashes]
return e
}()
extension Response {
static func json(_ value: some Content, status: HTTPResponseStatus = .ok) throws -> Response {
let response = Response(status: status)
try response.content.encode(value, using: relayJSONEncoder)
return response
}
}
// MARK: - §8.7/8.8 Attachments
struct AttachmentUploadResponse: Content {
let ok: Bool
let id: String
let bytes: Int
}
// MARK: - §8.9 Groups
struct CreateGroupRequest: Decodable {
let name: String
let members: [Int]?
}
struct UpdateMembersRequest: Decodable {
let add: [Int]?
let remove: [Int]?
}
/// 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
/// 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
/// lists.
struct GroupResponse: Content {
var ok: Bool?
let gid: String
let name: String
let owner: Int
let members: [Member]
struct Member: Content { let user_id: Int }
}
struct GroupListResponse: Content { let groups: [GroupResponse] }
struct OKResponse: Content { let ok: Bool }
// MARK: - §8.10 TURN
struct TurnResponse: Content {
let urls: [String]
let username: String
let credential: String
let ttl: Int
}
// MARK: - Messenger (non-E2E path)
struct BuddyRequest: Decodable { let buddy_id: Int }
struct BuddyView: Content { let user_id: Int; let display_name: String; let status: String }
struct BuddyListResponse: Content { let buddies: [BuddyView] }
struct PresenceRequest: Decodable { let status: String }
struct PresenceResponse: Content { let user_id: Int; let status: String }
struct SendMessageRequest: Decodable { let to: Int; let body: String }
struct SendMessageResponse: Content {
let ok: Bool
let id: Int
/// True when the recipient has neither added nor accepted the sender, so
/// this landed as a request rather than in their conversation list.
let pending: Bool
}
struct ThreadResponse: Content {
let with: Int
let messages: [StoredMessage]
struct StoredMessage: Content {
let id: Int
let from: Int
let to: Int
let body: String
let sent_at: String
let expires_at: String?
}
}
struct UnreadResponse: Content { let unread: Int }
struct ConversationRequest: Decodable {
let starred: Bool?
let archived: Bool?
let enc_mode: String?
let accept: Bool?
let disappear_after: Int?
}
// MARK: - Mobile compatibility surface (the iOS app's existing contract)
/// These shapes are dictated by the shipped iOS client, not chosen here. Field
/// names are load-bearing: the one bug this protocol has actually shipped was a
/// client and a server disagreeing about a field name, and nothing caught it
/// until a live round trip. Changing anything below is a client break.
struct MobileBuddyList: Content {
let buddies: [Row]
struct Row: Content {
let id: Int
let display_name: String
let screen_name: String?
/// online | away | offline — invisible and stale users read as offline.
let presence: String
let unread: Int
/// Written by hand so `screen_name` is emitted as `null` rather than
/// omitted.
///
/// Swift's synthesised encoder uses `encodeIfPresent` for optionals and
/// drops the key. Swift's *decoder* tolerates a missing key for an
/// optional, so the current client would not notice — but the PHP
/// emitted it, this is a compatibility surface, and matching the bytes
/// rather than relying on what happens to decode is the difference
/// between a port and a coincidence.
func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(display_name, forKey: .display_name)
try c.encode(screen_name, forKey: .screen_name)
try c.encode(presence, forKey: .presence)
try c.encode(unread, forKey: .unread)
}
}
}
struct MobileAddBuddyRequest: Decodable { let username: String }
/// The echo of a sent message. `who` is a display name, `time` is already
/// formatted for display — the client renders both verbatim.
struct MobileSendResponse: Content {
let id: Int
let who: String
let body: String
let time: String
}
struct MobilePollResponse: Content {
let messages: [MobileSendResponse]
/// The buddy's presence, folded into the poll so the client gets it without
/// a second request on every tick.
let status: String
}
struct MobileStatusRequest: Decodable { let status: String?; let message: String? }
struct MobileStatusResponse: Content { let status: String; let message: String }
struct MobileMessageOnly: Content { let message: String }
struct MobileConversationRequest: Decodable {
let action: String
let buddy: Int
/// Seconds, for `action: "disappear"`. 0 turns it off.
let seconds: Int?
}
// MARK: - Services, notifications, blocks, commands
struct ServicesStatusResponse: Content {
let aim: Service
let icq: Service
struct Service: Content {
let connected: Bool
let screen_name: String?
func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(connected, forKey: .connected)
try c.encode(screen_name, forKey: .screen_name) // null, never omitted
}
}
}
struct NotificationsResponse: Content {
let notifications: [Row]
let unread: Int
/// High-water mark, so the next poll can send it back as `after`.
let latest_id: Int
struct Row: Content {
let id: Int
let type: String
let message: String
let url: String
let is_read: Bool
let created_at: String
}
}
struct BlockListResponse: Content {
let blocked: [Row]
struct Row: Content { let id: Int; let name: String; let slug: String }
}
struct BlockToggleResponse: Content {
let blocked: Bool
let user_id: Int
let count: Int
}
struct CommandRequest: Decodable {
let name: String
let arg: String?
let buddy: Int?
}
struct CommandResponse: Content { let name: String; let message: String }
/// `GET /api/wp/v2/users/me` — WordPress's shape, because the client decodes it.
struct EnclaveSessionRequest: Decodable {
/// The account service's signed session token.
let token: String
/// What to call this person here, used only when the account is new.
let handle: String
let display_name: String?
}
struct MeResponse: Content {
let id: Int
let name: String
let slug: String
}
struct DeliveryKeyRequest: Decodable {
/// Base64 of `SHA-256(delivery_key)` — 32 bytes.
let digest: String
}
/// Compare two equal-length byte strings without an early exit.
///
/// The values here are digests, so a timing leak is not obviously exploitable —
/// but a comparison that returns early on the first differing byte is the kind
/// of thing that becomes exploitable after some later change, and the constant
/// time version costs nothing.
func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var diff: UInt8 = 0
for (x, y) in zip(a, b) { diff |= x ^ y }
return diff == 0
}
// MARK: - §19 Key transparency
struct LogHeadResponse: Content {
let size: Int
let root: String
/// Ed25519 over `"fnkt-v1|<size>|<root>"`, by the relay's issuer key.
let signature: String
let audience: String
}
struct LogInclusionResponse: Content {
let index: Int
let size: Int
let leaf: String
let root: String
let proof: [String]
}
struct LogConsistencyResponse: Content {
let from: Int
let to: Int
let from_root: String
let to_root: String
let proof: [String]
}
struct LogDeviceResponse: Content {
let device_id: String
let size: Int
let entries: [Entry]
struct Entry: Content {
let index: Int
let kind: String
let leaf: String
}
}
extension Data {
/// Decode base64url (RFC 4648 §5) — JWT's encoding, which Foundation's
/// base64 initialiser does not accept: `-` and `_` replace `+` and `/`,
/// and the padding is omitted.
init?(base64URLEncoded input: String) {
var s = input.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = s.count % 4
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
self.init(base64Encoded: s)
}
}
/// What this relay serves, as a document a client can read.
///
/// Modelled on WordPress's namespace index because the shipped client was
/// written against that one: it fetches `<prefix>/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
/// the client turns that feature off rather than calling routes that 404.
///
/// Keep the strings as literal paths. The client matches on them.
struct NamespaceIndex: Content {
let namespace: String
let routes: [String]
static let current = NamespaceIndex(
namespace: "flamenet/v1",
routes: [
"/flamenet/v1/messenger/signup",
"/flamenet/v1/messenger/signin",
"/flamenet/v1/messenger/session",
"/flamenet/v1/messenger/account",
"/flamenet/v1/messenger/buddies",
"/flamenet/v1/messenger/messages",
"/flamenet/v1/messenger/status",
"/flamenet/v1/messenger/conversation",
"/flamenet/v1/messenger/tier",
"/flamenet/v1/messenger/services",
"/flamenet/v1/messenger/notifications",
"/flamenet/v1/messenger/blocks",
"/flamenet/v1/messenger/command",
"/flamenet/v1/e2e/devices",
"/flamenet/v1/e2e/keys",
"/flamenet/v1/e2e/messages",
"/flamenet/v1/e2e/prekeys",
"/flamenet/v1/e2e/delivery-key",
"/flamenet/v1/e2e/attachments",
"/flamenet/v1/e2e/groups",
"/flamenet/v1/e2e/log",
"/flamenet/v1/e2e/turn",
])
}
@@ -0,0 +1,94 @@
import Crypto
import Foundation
import Testing
@testable import FlamenetRelay
/// The Node harness drives this server over HTTP, which is the right place for
/// almost everything. These are the things it cannot reach: pure functions
/// whose contract is a byte layout rather than a response.
///
/// This target also exists because an empty one is worse than none. Declared
/// with no sources, SPM resolves its path to the package root and fails a clean
/// checkout with "overlapping sources" — which is how it was found.
@Suite("At-rest encryption")
struct AtRestCryptoTests {
@Test("a sealed body round-trips")
func roundTrip() throws {
let key = AtRestCrypto.key(from: "a-secret")
let sealed = try AtRestCrypto.seal("hello, 2003", using: key)
#expect(sealed != "hello, 2003")
#expect(AtRestCrypto.open(sealed, using: key) == "hello, 2003")
}
@Test("the blob is iv || tag || ciphertext, not the combined form")
func layout() throws {
// The PHP wrote iv(12) || tag(16) || ct. `SealedBox.combined` puts the
// tag LAST, so a port that used it would produce blobs the old rows
// cannot be read alongside — and the mistake is invisible until a
// migration.
let key = AtRestCrypto.key(from: "a-secret")
let sealed = try AtRestCrypto.seal("x", using: key)
let raw = try #require(Data(base64Encoded: sealed))
#expect(raw.count == 12 + 16 + 1)
let nonce = try AES.GCM.Nonce(data: raw.prefix(12))
let box = try AES.GCM.SealedBox(
nonce: nonce, ciphertext: raw.dropFirst(28), tag: raw.dropFirst(12).prefix(16))
#expect(try AES.GCM.open(box, using: key) == Data("x".utf8))
}
@Test("the key derivation matches the PHP")
func keyDerivation() {
// sha256("fnmsg-" || secret), truncated to 32 bytes. Pinned as a
// literal so a refactor of the derivation cannot silently orphan every
// migrated row.
let key = AtRestCrypto.key(from: "a-secret")
let expected = Data(SHA256.hash(data: Data("fnmsg-a-secret".utf8)))
key.withUnsafeBytes { #expect(Data($0) == expected) }
}
@Test("a body that was never encrypted comes back unchanged")
func legacyPlaintext() {
let key = AtRestCrypto.key(from: "a-secret")
#expect(AtRestCrypto.open("just some words", using: key) == "just some words")
#expect(AtRestCrypto.open("", using: key) == "")
}
@Test("the wrong key yields the stored blob, not a crash or empty string")
func wrongKey() throws {
let sealed = try AtRestCrypto.seal("secret", using: AtRestCrypto.key(from: "one"))
let opened = AtRestCrypto.open(sealed, using: AtRestCrypto.key(from: "two"))
// Leniency is deliberate — see AtRestCrypto — and its cost is exactly
// this: a rotated secret is indistinguishable from a legacy row, so it
// renders as base64 rather than reporting.
#expect(opened == sealed)
}
}
@Suite("Presence")
struct PresenceTests {
@Test("a heartbeat is rounded down to the online window")
func coarsening() {
let t = Date(timeIntervalSince1970: 1_700_000_123)
let coarse = MessagingRoutes.coarsen(t)
#expect(coarse <= t)
#expect(t.timeIntervalSince(coarse) < MessagingRoutes.onlineWindow)
#expect(coarse.timeIntervalSince1970
.truncatingRemainder(dividingBy: MessagingRoutes.onlineWindow) == 0)
}
}
@Suite("base64url")
struct Base64URLTests {
@Test("round-trips without padding or url-unsafe characters")
func roundTrip() {
let bytes = Data((0..<64).map { UInt8($0) })
let encoded = base64URLEncode(bytes)
#expect(!encoded.contains("="))
#expect(!encoded.contains("+"))
#expect(!encoded.contains("/"))
#expect(base64URLDecode(encoded) == bytes)
}
}
+63
View File
@@ -0,0 +1,63 @@
# Flamenet relay + Postgres.
#
# 1. cp .env.example .env and fill in the two secrets it asks for
# 2. docker compose up -d
# 3. open http://localhost:8080
#
# The relay serves its own landing page, so a working install explains itself
# rather than answering with a 404.
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: flamenet
POSTGRES_USER: flamenet
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
volumes:
- db-data:/var/lib/postgresql/data
# Not published on the host: the relay reaches it over the compose network,
# and an exposed database is the kind of default that ends up on Shodan.
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flamenet -d flamenet"]
interval: 5s
timeout: 5s
retries: 10
relay:
build: .
image: flamenet-relay:dev
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
FN_HOST: "0.0.0.0"
# Errors and criticals only. `debug` logs request URLs and SQL, and a
# §8 URL contains a device id.
FN_LOG_LEVEL: "${FN_LOG_LEVEL:-error}"
FN_POSTGRES_URL: "postgres://flamenet:${POSTGRES_PASSWORD}@db:5432/flamenet"
FN_RELAY_AUDIENCE: "${FN_RELAY_AUDIENCE:-flamenet-relay}"
# Both are required and have no defaults. A relay that invented its own
# would mint tokens nobody else could verify and silently change the key
# every restart.
FN_ISSUER_PUBLIC_KEY: "${FN_ISSUER_PUBLIC_KEY:?generate an issuer keypair - see .env.example}"
FN_ISSUER_PRIVATE_KEY: "${FN_ISSUER_PRIVATE_KEY:?generate an issuer keypair - see .env.example}"
FN_AT_REST_SECRET: "${FN_AT_REST_SECRET:?set FN_AT_REST_SECRET in .env}"
# Donations are off unless both are set. Card details never reach this
# service: the form posts an amount and the relay redirects to the
# processor's own hosted checkout.
FN_DONATE_PROVIDER: "${FN_DONATE_PROVIDER:-}"
FN_DONATE_SECRET: "${FN_DONATE_SECRET:-}"
ports:
- "127.0.0.1:8080:8080"
volumes:
# Attachments are files, not rows. Postgres holds everything else.
- attachments:/data/attachments
volumes:
db-data:
attachments: