A standing gate for the seven paths that must never break #95

Merged
paulhitt merged 2 commits from feat/validate-attachments into main 2026-09-04 20:40:33 +00:00
5 changed files with 557 additions and 1 deletions
@@ -0,0 +1,79 @@
import Foundation
/// Checking that what is served is what was stored (relay#26).
///
/// 🔴 **The relay cannot check what an attachment IS — only that it is still
/// the same size it was.** The blob is sealed before it arrives, so its content
/// is noise here and no amount of inspection would tell a photograph from a
/// probe. What the relay does know is the byte count it recorded at upload, and
/// a served blob whose length disagrees with that is not the file somebody
/// uploaded, whatever else it might be.
///
/// ⚠️ **This is not a substitute for the client's own check, and must not be
/// read as one.** The sender seals with AES-GCM and the recipient's tag check
/// is what actually proves the bytes are unmodified — a relay could serve a
/// wrong blob of exactly the right length and only the recipient would catch
/// it. What a length check adds is a clear answer for the far commoner case:
/// a truncated file from a half-finished write or a disk that filled up.
///
/// The difference matters in what somebody is told. A truncated blob that
/// reaches the client fails as a decryption error, which reads as "your
/// message is corrupt". Caught here it reads as "the server lost part of this
/// file", which is true and points at the right place.
enum AttachmentIntegrity {
enum Verdict: Equatable {
case ok
/// The bytes on disk are shorter than the index says. A partial write,
/// a full disk, or a truncating copy.
case truncated(expected: Int, found: Int)
/// Longer than recorded, which should be impossible and therefore
/// matters more: something appended to a blob after it was indexed.
case longer(expected: Int, found: Int)
/// Nothing recorded to compare against. An older row, not a fault.
case unknown
}
static func check(expected: Int?, found: Int) -> Verdict {
guard let expected, expected > 0 else { return .unknown }
if found == expected { return .ok }
return found < expected
? .truncated(expected: expected, found: found)
: .longer(expected: expected, found: found)
}
/// Whether to serve it.
///
/// 🔴 Refuses on either mismatch. Serving a blob that is not what was
/// stored hands the client a decryption failure and no way to tell that
/// from a genuine one — and for the `longer` case, serving extra bytes the
/// uploader never sent is the worse of the two.
///
/// ⚠️ Serves when there is nothing to compare against. A row written
/// before the byte count existed is not evidence of damage, and refusing
/// on an unknown would delete access to every older attachment.
static func shouldServe(_ verdict: Verdict) -> Bool {
switch verdict {
case .ok, .unknown: return true
case .truncated, .longer: return false
}
}
/// What to put in the log. Nil when there is nothing wrong.
static func report(token: String, verdict: Verdict) -> String? {
switch verdict {
case .ok, .unknown:
return nil
case .truncated(let expected, let found):
return "attachment \(token) is truncated: indexed \(expected) bytes, "
+ "found \(found). Refusing to serve it — a short blob reaches "
+ "the client as a decryption failure, which is indistinguishable "
+ "from a real one. Storage lost part of this file."
case .longer(let expected, let found):
return "attachment \(token) is LONGER than indexed: expected \(expected) "
+ "bytes, found \(found). Refusing to serve it. Nothing in the "
+ "upload path can produce this, so something wrote to the blob "
+ "after it was stored."
}
}
}
@@ -16,6 +16,13 @@ import Vapor
/// capacity decision.
protocol AttachmentStore: Sendable {
func write(_ token: String, data: Data) async throws
/// The stored size, or nil when the backend cannot say cheaply.
///
/// ⚠️ Optional by design. A local file has a length for free; an object
/// store would need a HEAD, and paying a round trip on every download to
/// catch a rare fault is the wrong trade. A backend that cannot answer
/// returns nil and the check is skipped rather than guessed at.
func storedSize(_ token: String) async -> Int?
/// The blob as a Response, or nil when the bytes are gone.
func respond(_ token: String, request: Request) async throws -> Response?
/// Remove the bytes. Absence counts as success — the goal is "not there".
@@ -36,6 +43,12 @@ struct DirectoryAttachmentStore: AttachmentStore {
options: .atomic)
}
func storedSize(_ token: String) async -> Int? {
let path = attachmentPath(directory, token)
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
return (attributes?[.size] as? NSNumber)?.intValue
}
func respond(_ token: String, request: Request) async throws -> Response? {
let path = attachmentPath(directory, token)
guard FileManager.default.fileExists(atPath: path) else { return nil }
@@ -80,6 +93,12 @@ struct S3AttachmentStore: AttachmentStore {
URI(string: "\(s3.endpoint)/\(s3.bucket)/\(key(token))")
}
/// ⚠️ Nil, deliberately. Answering would cost a HEAD on every download,
/// and paying a round trip per request to catch a rare storage fault is
/// the wrong trade. The local store answers for free and gets the check;
/// this one skips it rather than guessing.
func storedSize(_ token: String) async -> Int? { nil }
func write(_ token: String, data: Data) async throws {
let response = try await signedRequest(.PUT, token: token, body: data)
guard response.status == .ok else {
+16 -1
View File
@@ -92,10 +92,25 @@ extension Routes {
guard isValidAttachmentToken(token) else {
throw WireError.badRequest("Malformed attachment id.")
}
guard try await Attachment.find(token, on: request.db) != nil else {
guard let row = try await Attachment.find(token, on: request.db) else {
throw WireError.notFound("No such attachment.")
}
// relay#26 — what is served must be what was stored. The relay cannot
// check what a sealed blob IS, but it recorded how long it was, and a
// blob of a different length is not the file somebody uploaded.
if let found = await attachmentStore.storedSize(token) {
let verdict = AttachmentIntegrity.check(expected: row.bytes, found: found)
if let complaint = AttachmentIntegrity.report(token: token, verdict: verdict) {
request.logger.error("\(complaint)")
}
guard AttachmentIntegrity.shouldServe(verdict) else {
// 🔴 Gone rather than corrupt. Serving it hands the client a
// decryption failure indistinguishable from a real one.
throw WireError.notFound("No such attachment.")
}
}
guard let response = try await attachmentStore.respond(token, request: request) else {
// Indexed but absent: retention removed the bytes, or the write
// half-failed. Either way it is gone, and saying 404 is honest.
@@ -0,0 +1,70 @@
import XCTest
@testable import FlamenetRelay
/// Checking that what is served is what was stored (relay#26).
///
/// 🔴 **The relay cannot check what an attachment IS — only that it is still
/// the same size.** The blob is sealed before it arrives, so its content is
/// noise here. What the relay knows is the byte count it recorded at upload,
/// and a blob of a different length is not the file somebody uploaded.
///
/// ⚠️ These tests also pin what this does NOT replace: the recipient's AES-GCM
/// tag check is what actually proves the bytes are unmodified. A relay could
/// serve a wrong blob of exactly the right length and only the recipient would
/// catch it. This catches the far commoner case — a truncated file — and turns
/// it from "your message is corrupt" into "the server lost part of this file".
final class AttachmentIntegrityTests: XCTestCase {
func testMatchingLengthsAreFine() {
XCTAssertEqual(AttachmentIntegrity.check(expected: 1024, found: 1024), .ok)
XCTAssertTrue(AttachmentIntegrity.shouldServe(.ok))
}
func testAShortBlobIsTruncated() {
let verdict = AttachmentIntegrity.check(expected: 1024, found: 512)
XCTAssertEqual(verdict, .truncated(expected: 1024, found: 512))
XCTAssertFalse(AttachmentIntegrity.shouldServe(verdict))
}
/// 🔴 Longer than recorded should be impossible, which is why it matters
/// more rather than less: something wrote to the blob after it was indexed.
func testALongerBlobIsRefusedToo() {
let verdict = AttachmentIntegrity.check(expected: 1024, found: 2048)
XCTAssertEqual(verdict, .longer(expected: 1024, found: 2048))
XCTAssertFalse(AttachmentIntegrity.shouldServe(verdict))
}
/// ⚠️ A row written before the byte count existed is not evidence of
/// damage. Refusing on unknown would delete access to every older
/// attachment on the deployment.
func testAnUnknownLengthIsStillServed() {
XCTAssertEqual(AttachmentIntegrity.check(expected: nil, found: 900), .unknown)
XCTAssertEqual(AttachmentIntegrity.check(expected: 0, found: 900), .unknown)
XCTAssertTrue(AttachmentIntegrity.shouldServe(.unknown))
}
func testNothingIsLoggedWhenNothingIsWrong() {
XCTAssertNil(AttachmentIntegrity.report(token: "abc", verdict: .ok))
XCTAssertNil(AttachmentIntegrity.report(token: "abc", verdict: .unknown))
}
/// ⚠️ The log has to say where the fault is. "Attachment corrupt" sends
/// somebody looking at the client.
func testTheLogPointsAtStorageRatherThanAtTheClient() throws {
let message = try XCTUnwrap(AttachmentIntegrity.report(
token: "deadbeef", verdict: .truncated(expected: 1024, found: 512)))
XCTAssertTrue(message.contains("deadbeef"), message)
XCTAssertTrue(message.contains("1024"), message)
XCTAssertTrue(message.contains("512"), message)
XCTAssertTrue(message.contains("Storage lost part of this file"), message)
// And why it is refused rather than served short.
XCTAssertTrue(message.contains("indistinguishable"), message)
}
func testALongerBlobSaysNothingCanProduceIt() throws {
let message = try XCTUnwrap(AttachmentIntegrity.report(
token: "cafe", verdict: .longer(expected: 10, found: 99)))
XCTAssertTrue(message.contains("LONGER"), message)
XCTAssertTrue(message.contains("after it was stored"), message)
}
}
@@ -0,0 +1,373 @@
import Crypto
import Fluent
import Foundation
import Testing
import Vapor
import VaporTesting
@testable import FlamenetRelay
/// The seven things that must never stop working.
///
/// 🔴 **This suite exists because the unit tests stayed green through a
/// two-day outage.** On 2 September twenty-eight merges left sign-in,
/// messages, calls and stories broken on both clients while 489 Android unit
/// tests and the relay's own suite reported success throughout — because they
/// test units. "assemble produces this JSON" is a unit test; "an account can
/// still sign in and send a story" is not, and nobody was asking it.
///
/// So this is the standing gate for backend work: sign-up, sign-in, messages,
/// stories, files, wallet and calls, each exercised over HTTP through the real
/// stack. A relay change that breaks one of them fails here rather than on
/// somebody's phone.
///
/// ⚠️ **What it can and cannot prove.** Every payload here is ciphertext to the
/// relay, so this cannot check that a story renders or that audio flows. What
/// it checks is the part the relay can break: that the routes each flow depends
/// on are mounted, authorised, and carry what was put in them. That is the
/// boundary these changes actually touch.
///
/// ⚠️ Nested inside `EndToEndTests` for its `.serialized` trait — two top-level
/// suites run in parallel and both set `FN_DB`.
extension EndToEndTests {
@Suite("core paths")
struct CorePaths {
private struct Session {
let credential: String
let userID: Int
let deviceID: String
let key: Curve25519.Signing.PrivateKey
var token: String
}
private func withRelayApp(_ body: (Application) async throws -> Void) async throws {
let issuer = Curve25519.Signing.PrivateKey()
let dbPath = NSTemporaryDirectory() + "fn-core-\(UUID().uuidString).sqlite"
let attachments = NSTemporaryDirectory() + "fn-core-att-\(UUID().uuidString)"
let env: [String: String] = [
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
"FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
"FN_DB": dbPath,
"FN_ATTACHMENTS_DIR": attachments,
"FN_LOG_LEVEL": "critical",
"FN_SIGNUP_MAX_PER_HOUR": "50",
]
let previous = env.keys.reduce(into: [String: String?]()) {
$0[$1] = ProcessInfo.processInfo.environment[$1]
}
for (k, v) in env { setenv(k, v, 1) }
defer {
for (k, v) in previous { if let v { setenv(k, v, 1) } else { unsetenv(k) } }
try? FileManager.default.removeItem(atPath: dbPath)
try? FileManager.default.removeItem(atPath: attachments)
}
let app = try await Application.make(.testing)
do { try await configure(app); try await body(app) }
catch { try? await app.asyncShutdown(); throw error }
try? await app.asyncShutdown()
}
// MARK: - 1. Sign-up
private func signUp(_ app: Application, _ name: String) async throws -> (String, Int) {
var credential = ""
var userID = 0
try await app.testing().test(
.POST, "/account/signup",
beforeRequest: { req in
try req.content.encode(["username": name, "email": "\(name)@example.com",
"password": "hunter2hunter2"])
},
afterResponse: { res in
#expect(res.status == .ok, "sign-up stopped working")
struct Made: Decodable { let app_password: String; let user_id: Int }
let made = try res.content.decode(Made.self)
credential = made.app_password
userID = made.user_id
})
return (credential, userID)
}
// MARK: - 2. Sign-in
/// ⚠️ Sign-in is TWO steps and both have to work: the account credential
/// authenticates, and a device token is minted from it. A relay that
/// accepts the credential but refuses the token leaves somebody stuck on a
/// spinner, which is what "signed in but never reached the shell" looked
/// like from the outside.
private func signIn(_ app: Application, _ name: String, credential: String,
userID: Int, device: String) async throws -> Session {
let key = Curve25519.Signing.PrivateKey()
let ikSig = key.publicKey.rawRepresentation.base64EncodedString()
var headers = HTTPHeaders()
headers.basicAuthorization = .init(username: name, password: credential)
var token = ""
try await app.testing().test(
.POST, "/account/token", headers: headers,
beforeRequest: { req in
try req.content.encode(["device_id": device, "ik_sig": ikSig])
},
afterResponse: { res in
#expect(res.status == .ok, "sign-in stopped minting device tokens")
struct Minted: Decodable { let token: String }
token = try res.content.decode(Minted.self).token
})
#expect(!token.isEmpty, "a token was issued but it was empty")
return Session(credential: credential, userID: userID,
deviceID: device, key: key, token: token)
}
private func authed(_ s: Session, _ method: HTTPMethod, _ path: String,
body: String = "") -> HTTPHeaders {
var headers = HTTPHeaders()
headers.bearerAuthorization = .init(token: s.token)
let ts = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
let digest = SHA256.hash(data: Data(body.utf8))
.map { String(format: "%02x", $0) }.joined()
let canonical = "\(method.string)\n\(path)\n\(ts)\n\(nonce)\n\(digest)"
let proof = try! s.key.signature(for: Data(canonical.utf8))
headers.add(name: FlamenetRelay.Routes.Headers.timestamp, value: ts)
headers.add(name: FlamenetRelay.Routes.Headers.nonce, value: nonce)
headers.add(name: FlamenetRelay.Routes.Headers.signature,
value: proof.base64EncodedString())
headers.contentType = .json
return headers
}
private func register(_ app: Application, _ s: Session) async throws {
let ikSig = s.key.publicKey.rawRepresentation.base64EncodedString()
let body = #"{"device_id":"\#(s.deviceID)","ik_dh":"\#(ikSig)","ik_sig":"\#(ikSig)","spk_id":1,"spk":"spk","spk_sig":"sig","prekeys":[{"id":1,"pub":"opk1"},{"id":2,"pub":"opk2"}]}"#
try await app.testing().test(
.POST, "/e2e/devices",
headers: authed(s, .POST, "/e2e/devices", body: body),
beforeRequest: { $0.body = .init(string: body) },
afterResponse: { #expect($0.status == .ok, "device registration stopped working") })
}
private func pair(_ app: Application) async throws -> (Session, Session) {
let (adaCred, adaID) = try await signUp(app, "ada")
let (bobCred, bobID) = try await signUp(app, "bob")
let ada = try await signIn(app, "ada", credential: adaCred,
userID: adaID, device: "aaaa-0001")
let bob = try await signIn(app, "bob", credential: bobCred,
userID: bobID, device: "bbbb-0002")
try await register(app, ada)
try await register(app, bob)
return (ada, bob)
}
/// Send one sealed envelope and read it back. Every flow below is a
/// different payload through this same path, which is exactly why one
/// break takes them all.
private func deliver(_ app: Application, from sender: Session, to peer: Session,
payload: String, what: String) async throws {
let send = #"{"to":\#(peer.userID),"messages":[{"to_device":"\#(peer.deviceID)","payload":"\#(payload)"}]}"#
try await app.testing().test(
.POST, "/e2e/messages",
headers: authed(sender, .POST, "/e2e/messages", body: send),
beforeRequest: { $0.body = .init(string: send) },
afterResponse: { #expect($0.status == .ok, "\(what): the send was refused") })
try await app.testing().test(
.GET, "/e2e/messages",
headers: authed(peer, .GET, "/e2e/messages")
) { res in
#expect(res.status == .ok, "\(what): the poll was refused")
#expect(res.body.string.contains(payload),
"\(what): it was accepted and did not come back")
}
}
// MARK: - The seven
@Test("sign-up and sign-in still work end to end")
func signUpAndSignIn() async throws {
try await withRelayApp { app in
let (credential, userID) = try await signUp(app, "ada")
#expect(!credential.isEmpty)
#expect(userID > 0)
let session = try await signIn(app, "ada", credential: credential,
userID: userID, device: "aaaa-0001")
try await register(app, session)
// 🔴 And the credential must keep working. A token that mints once
// and then refuses is the shape of "session expired" on a phone
// that never left.
let again = try await signIn(app, "ada", credential: credential,
userID: userID, device: "aaaa-0002")
#expect(!again.token.isEmpty, "a second sign-in was refused")
}
}
@Test("messages still send and arrive")
func messages() async throws {
try await withRelayApp { app in
let (ada, bob) = try await pair(app)
try await deliver(app, from: ada, to: bob,
payload: "ciphertext-message", what: "messages")
}
}
/// 🔴 A story is not a broadcast here — it is one sealed envelope per
/// recipient, because the relay must not learn who an author's contacts
/// are. So "stories work" at this boundary means the fan-out and the poll
/// work, and a story with a picture also needs the attachment path.
@Test("stories still fan out and arrive")
func stories() async throws {
try await withRelayApp { app in
let (ada, bob) = try await pair(app)
// The picture first: a story's blob is an ordinary attachment.
var token = ""
let blob = String(repeating: "s", count: 64)
try await app.testing().test(
.POST, "/e2e/attachments",
headers: authed(ada, .POST, "/e2e/attachments", body: blob),
beforeRequest: { $0.body = .init(string: blob) },
afterResponse: { res in
#expect(res.status == .ok, "stories: the blob upload was refused")
struct Up: Decodable { let id: String }
token = try res.content.decode(Up.self).id
})
#expect(!token.isEmpty)
// Then one envelope naming it, to each viewer.
try await deliver(app, from: ada, to: bob,
payload: "story-envelope-\(token)", what: "stories")
// And the viewer can fetch the blob it names.
try await app.testing().test(
.GET, "/e2e/attachments/\(token)",
headers: authed(bob, .GET, "/e2e/attachments/\(token)")
) { res in
#expect(res.status == .ok, "stories: the viewer could not fetch the picture")
}
}
}
/// Files ride the same sealed-blob path as a story's picture, with the
/// reference travelling inside the ratchet.
@Test("files still upload, travel and download")
func files() async throws {
try await withRelayApp { app in
let (ada, bob) = try await pair(app)
let contents = String(repeating: "f", count: 128)
var token = ""
try await app.testing().test(
.POST, "/e2e/attachments",
headers: authed(ada, .POST, "/e2e/attachments", body: contents),
beforeRequest: { $0.body = .init(string: contents) },
afterResponse: { res in
#expect(res.status == .ok, "files: the upload was refused")
struct Up: Decodable { let id: String; let bytes: Int }
let up = try res.content.decode(Up.self)
token = up.id
#expect(up.bytes == contents.utf8.count,
"files: the relay recorded a different size than it received")
})
try await deliver(app, from: ada, to: bob,
payload: "file-ref-\(token)", what: "files")
try await app.testing().test(
.GET, "/e2e/attachments/\(token)",
headers: authed(bob, .GET, "/e2e/attachments/\(token)")
) { res in
#expect(res.status == .ok, "files: the recipient could not download it")
#expect(res.body.string == contents,
"files: what came back was not what went in")
}
}
}
/// 🔴 **The wallet does not touch the relay, and this test exists to keep
/// that true.** Keys are derived on the device from a BIP-39 phrase and
/// balances come from a Solana node the person chooses — nothing in this
/// server is on that path. So the guarantee worth pinning is a negative
/// one: no backend change can break the wallet, because there is no
/// backend route for it to break.
///
/// ⚠️ If this ever fails, the wallet has grown a server dependency and the
/// privacy story around it has changed. That is a decision, not a bug fix.
@Test("the wallet still needs nothing from the relay")
func wallet() async throws {
try await withRelayApp { app in
for path in ["/e2e/wallet", "/e2e/balance", "/account/wallet"] {
try await app.testing().test(.GET, path) { res in
#expect(res.status == .notFound || res.status == .unauthorized,
"\(path) answers — the wallet has grown a server dependency")
}
}
}
}
/// Call signalling rides the ratchet as ordinary content envelopes, so the
/// relay cannot tell an offer from a photo — which is the point. What it
/// can break is the envelope path and the TURN credential route.
@Test("calls still signal, and TURN answers honestly")
func calls() async throws {
try await withRelayApp { app in
let (ada, bob) = try await pair(app)
// Offer, answer, end — three envelopes over the same path.
for signal in ["call-offer", "call-answer", "call-end"] {
try await deliver(app, from: ada, to: bob,
payload: signal, what: "calls (\(signal))")
}
// 🔴 A relay with no TURN configured must say 503, not 500. The
// client reads that as "no relay server here, expect P2P-or-fail"
// and says so in the call banner; a 500 reads as a broken relay and
// sends somebody looking in the wrong place.
try await app.testing().test(
.GET, "/e2e/turn", headers: authed(ada, .GET, "/e2e/turn")
) { res in
#expect(res.status == .serviceUnavailable || res.status == .ok,
"calls: /e2e/turn answered \(res.status). A relay with no TURN must refuse with 503, which the client reads as expect-P2P-or-fail; a 500 sends somebody looking in the wrong place.")
}
}
}
/// ⚠️ One test that walks all of it in order, because the flows share a
/// session and a break in an earlier one can be masked by a fresh account
/// in a later test.
@Test("all seven survive one account's lifetime")
func everythingInOneLife() async throws {
try await withRelayApp { app in
let (ada, bob) = try await pair(app)
try await deliver(app, from: ada, to: bob, payload: "m1", what: "messages")
var token = ""
let blob = String(repeating: "x", count: 64)
try await app.testing().test(
.POST, "/e2e/attachments",
headers: authed(ada, .POST, "/e2e/attachments", body: blob),
beforeRequest: { $0.body = .init(string: blob) },
afterResponse: { res in
#expect(res.status == .ok)
struct Up: Decodable { let id: String }
token = try res.content.decode(Up.self).id
})
try await deliver(app, from: ada, to: bob,
payload: "story-\(token)", what: "stories")
try await deliver(app, from: bob, to: ada,
payload: "file-\(token)", what: "files")
try await deliver(app, from: bob, to: ada,
payload: "call-offer", what: "calls")
// And the account still works at the end of all that.
let again = try await signIn(app, "ada", credential: ada.credential,
userID: ada.userID, device: "aaaa-0003")
#expect(!again.token.isEmpty, "the account stopped working after ordinary use")
}
}
}
}