148 lines
6.2 KiB
Swift
148 lines
6.2 KiB
Swift
|
|
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)
|
||
|
|
}
|
||
|
|
}
|