32 conflict hunks across 21 files. The two sides are the 0.32 feature branch (cards, events, games, Today, the small-web directory, hello links, story take-downs, stickers) and main (the Spanish catalog, attachment padding, the stage 2-B request gate, bridge offers). Neither side is the answer anywhere the conflict is about what the app does. What the resolutions keep, and why: Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact links, the other `flamenetmessenger` for hello links. Taking either side alone would have stopped the other kind of link opening the app at all. CardScanner: git had already merged the controller to the union, with both the injected `accepts` predicate and the new `onRefused` callback. The call sites now match it. `accepts` is the only gate, because hard-coding `ScannedCode` there would have refused every code the Devices screen scans. The address sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older card's `?u=` username is still never trusted (DESIGN 32). E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather than into the release branch's new `connect()`. It reads this device's own store and never the relay, and the offline launch path passes `connect: false`, which would otherwise show every existing conversation as a request until the relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and the release branch's control-traffic cases. E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in `all`, and in the shape test. MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are kept, with the `identifier` field put back and every title through `String(localized:)`. `title` is a plain String, so the release branch's bare literals would have left every tab English in every language. ParentalControlsView: the release branch's fuller footer, written as ONE literal. `Text` localizes a literal and not a `+` chain, so the concatenated form would have taken that footer out of the catalog. Eleven more places take the union with the localized string kept: the attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView, ChatView, ChatViewModel and StoriesFeedView. Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved anonymous reads and contact links to §8.13 and §8.14 to make room for the push token and the Today file at §8.11 and §8.12, so every reference in this tree to "8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong section. The Today and push references were already right and are untouched. .e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified spec, which is also what Android pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.6 KiB
Swift
147 lines
6.6 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
/// Section 8.13, stage D2: reads about OTHER people that name nobody.
|
|
///
|
|
/// Every read here is a POST with its target in the body, never in the URL,
|
|
/// so the target is in no proxy's access log; and it goes out on its own
|
|
/// HTTP client: no `Authorization` header, no cookies, no cache, its own
|
|
/// connection pool, a generic User-Agent. A connection that carried an
|
|
/// authenticated request never carries one of these.
|
|
///
|
|
/// What the relay still sees, and the app says so where it matters: the IP
|
|
/// address, which is usually the one this device polls from seconds apart.
|
|
/// What changes is what is stored.
|
|
final class AnonymousRelayClient {
|
|
|
|
private let base: URL
|
|
/// Follows the bridge setting (`BridgedSession`).
|
|
private let sessions: BridgedSession
|
|
var session: URLSession { sessions.session }
|
|
|
|
/// - Parameter base: the relay's `…/flamenet/v1` root.
|
|
init(base: URL, protocolClasses: [AnyClass]? = nil) {
|
|
self.base = base
|
|
let cfg = URLSessionConfiguration.ephemeral
|
|
cfg.httpCookieStorage = nil
|
|
cfg.httpShouldSetCookies = false
|
|
cfg.httpCookieAcceptPolicy = .never
|
|
cfg.urlCache = nil
|
|
cfg.urlCredentialStorage = nil
|
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
cfg.timeoutIntervalForRequest = 25
|
|
cfg.timeoutIntervalForResource = 120
|
|
cfg.httpAdditionalHeaders = ["User-Agent": "FlameNet"]
|
|
if let protocolClasses { cfg.protocolClasses = protocolClasses }
|
|
self.sessions = BridgedSession { cfg.copy() as! URLSessionConfiguration }
|
|
}
|
|
|
|
/// The most tokens one presence request may carry (decision DD3).
|
|
static let presenceBatch = 8
|
|
|
|
/// Presence for up to eight tokens, keyed by token.
|
|
func presence(tokens: [String]) async throws -> [String: String] {
|
|
precondition(tokens.count <= Self.presenceBatch)
|
|
struct Row: Decodable {
|
|
let token: String
|
|
let presence: String
|
|
}
|
|
struct Answer: Decodable { let presence: [Row] }
|
|
let answer: Answer = try await post("/e2e/anon/presence", ["tokens": tokens])
|
|
return Dictionary(
|
|
answer.presence.map { ($0.token, $0.presence) }, uniquingKeysWith: { a, _ in a })
|
|
}
|
|
|
|
/// Identity keys for an account, no times. `cap` is a presence token the
|
|
/// account issued this one, which the relay meters by instead of the IP.
|
|
func devices(of userID: Int, cap: String?) async throws -> E2EDevicesResponse {
|
|
var body: [String: Any] = ["user_id": userID]
|
|
if let cap { body["cap"] = cap }
|
|
return try await post("/e2e/anon/devices", body)
|
|
}
|
|
|
|
/// Prekey bundles for an account, popping one prekey per device.
|
|
func bundles(of userID: Int, cap: String?) async throws -> E2EKeysResponse {
|
|
var body: [String: Any] = ["user_id": userID]
|
|
if let cap { body["cap"] = cap }
|
|
return try await post("/e2e/anon/bundles", body)
|
|
}
|
|
|
|
func introChallenge() async throws -> E2EIntroChallenge {
|
|
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro/challenge"))
|
|
req.httpMethod = "GET"
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
return try await perform(req)
|
|
}
|
|
|
|
/// An introduction by handle (after the proof of work) or by a reusable
|
|
/// link (section 14.5). Nil when the relay answers "no such account",
|
|
/// which is also its answer for an unlisted handle and a revoked link.
|
|
func intro(handle: String, challenge: String, nonce: String) async throws -> E2EIntroAnswer? {
|
|
try await intro(["handle": handle, "challenge": challenge, "nonce": nonce])
|
|
}
|
|
|
|
func intro(link: Data) async throws -> E2EIntroAnswer? {
|
|
try await intro(["link": Base64URL.encode(link)])
|
|
}
|
|
|
|
private func intro(_ body: [String: Any]) async throws -> E2EIntroAnswer? {
|
|
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro"))
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
let (data, response) = try await session.data(for: req)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw MessengerError.transport("Non-HTTP response")
|
|
}
|
|
if http.statusCode == 404 { return nil }
|
|
if http.statusCode == 429 {
|
|
throw MessengerError.rateLimited(
|
|
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
|
}
|
|
guard (200..<300).contains(http.statusCode),
|
|
let o = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let uid = JSONValue(any: o["user_id"])?.intValue,
|
|
let introB64 = o["intro"] as? String, let intro = Data(base64Encoded: introB64),
|
|
intro.count == 32,
|
|
let exp = JSONValue(any: o["exp"])?.intValue,
|
|
let rawDevices = o["devices"]
|
|
else { throw MessengerError.httpStatus(http.statusCode) }
|
|
let devices = try JSONDecoder().decode(
|
|
[E2EKeysResponse.Bundle].self,
|
|
from: JSONSerialization.data(withJSONObject: rawDevices))
|
|
return E2EIntroAnswer(
|
|
userID: Int(uid), fnid: o["fnid"] as? String, intro: intro, exp: Int(exp),
|
|
handleCert: (o["handle_cert"] as? [String: Any]).flatMap(HandleCertificate.init(json:)),
|
|
devices: devices)
|
|
}
|
|
|
|
private func post<T: Decodable>(_ path: String, _ body: [String: Any]) async throws -> T {
|
|
var req = URLRequest(url: base.appendingPathComponent(path))
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
return try await perform(req)
|
|
}
|
|
|
|
private func perform<T: Decodable>(_ req: URLRequest) async throws -> T {
|
|
let (data, response) = try await session.data(for: req)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw MessengerError.transport("Non-HTTP response")
|
|
}
|
|
if http.statusCode == 429 {
|
|
throw MessengerError.rateLimited(
|
|
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw MessengerError.httpStatus(http.statusCode)
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(T.self, from: data)
|
|
} catch {
|
|
throw MessengerError.decoding(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|