Files
Nearfield_Friends/Proximity/Services/Network/APIClient.swift

148 lines
6.2 KiB
Swift
Raw Normal View History

import Foundation
import CryptoKit
/// Backend client for the Proximity relay.
///
/// The server's role is deliberately narrow and privacy-preserving:
/// - It **relays anonymous tokens** so two present devices can discover each
/// other (especially for UWB, which needs a peer token),
/// - It **correlates mutual encounters** (token A near token B) without ever
/// learning identities,
/// - It **delivers E2E-encrypted messages** (ciphertext only).
///
/// The server never stores precise location history or identity by default.
struct APIClient {
private let baseURL: URL
private let session = URLSession.shared
/// Point at the production API.
init() {
self.baseURL = URL(string: "https://api.proximity.app")!
}
/// Point at a custom backend (e.g. the local reference server).
init(baseURL: URL) {
self.baseURL = baseURL
}
// MARK: - Auth
/// Exchange a provider ID token for a Proximity session.
func exchangeToken(_ result: AuthResult) async throws -> Session {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/exchange"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"provider": result.providerID.rawValue,
"idToken": result.idToken,
"nonce": result.nonce ?? ""
])
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(Session.self, from: data)
}
/// Revoke a Proximity session.
func revokeSession(_ session: Session) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/revoke"))
request.httpMethod = "POST"
request.setValue("Bearer \(session.token)", forHTTPHeaderField: "Authorization")
_ = try? await session.data(for: request)
}
// MARK: - Token relay
/// Register our current anonymous token so nearby peers can find us.
func registerToken(_ token: String, tier: DistanceTier) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/token"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"token": token,
"tier": tier.rawValue
])
_ = try await session.data(for: request)
}
/// Fetch anonymous tokens of present users near our coarse location.
func fetchNearbyTokens(geohash: String, tier: DistanceTier) async throws -> [String] {
var components = URLComponents(
url: baseURL.appendingPathComponent("v1/nearby"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "geohash", value: geohash),
URLQueryItem(name: "tier", value: String(tier.rawValue))
]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode([String].self, from: data)
}
// MARK: - Encounters
/// Send a "wave" to a silhouette signals interest in mutual reveal.
func wave(to remoteToken: String) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/wave"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(["token": remoteToken])
_ = try? await session.data(for: request)
}
/// Block a token permanently removes it from the graph.
func block(token: String) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/block"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(["token": token])
_ = try? await session.data(for: request)
}
/// Report a local encounter so the server can correlate the mutual side.
func reportEncounter(_ encounter: Encounter) async {
// Fire-and-forget; failures are non-critical.
var request = URLRequest(url: baseURL.appendingPathComponent("v1/encounter"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(encounter)
_ = try? await session.data(for: request)
}
// MARK: - Reveal / key exchange
/// Upload our E2E public key so a mutual peer can fetch it.
func uploadPublicKey(_ key: Curve25519.KeyAgreement.PublicKey) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/peer-key"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"publicKey": key.rawRepresentation.base64EncodedString()
])
_ = try await session.data(for: request)
}
/// Fetch the peer's public key for a mutual encounter, via the relay.
func fetchPeerPublicKey(for remoteToken: String) async throws -> Curve25519.KeyAgreement.PublicKey {
var components = URLComponents(
url: baseURL.appendingPathComponent("v1/peer-key"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [URLQueryItem(name: "token", value: remoteToken)]
let (data, _) = try await session.data(from: components.url!)
struct KeyResponse: Decodable { let publicKey: Data }
let response = try JSONDecoder().decode(KeyResponse.self, from: data)
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: response.publicKey)
}
// MARK: - Messaging (E2E)
/// Send an encrypted message. Only ciphertext leaves the device.
func sendMessage(_ message: ChatMessage) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/message"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(message)
_ = try await session.data(for: request)
}
}