// SPDX-License-Identifier: MIT // Copyright (c) 2026 Thibault Ducray // // This file is part of MyPwdTool's open-source sync/encryption core — see // LICENSE-SYNC-CRYPTO.md at the repo root and https://tducray.fr/mypwdtool/open-source/. // The rest of this application is proprietary and NOT covered by this license. package fr.tducray.mypwdtool.relay import fr.tducray.mypwdtool.appsecrets.RelayMasterKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse /** * Port of `RelayAPIClient` in `Sync/RelayAPIClient.swift`. Built on `java.net.http.HttpClient` * rather than Ktor, deliberately — the guiding principle for this whole client (agreed * 2026-07-24) is staying as close to plain JVM APIs as possible: Windows, Linux, and Android are * all "just a JVM" from this code's point of view, so the less this relies on KMP abstractions * over per-platform APIs, the more of it runs completely unmodified across all three. Revisit if * a true native (`mingwX64()`) target ever gets added — `java.net.http` obviously isn't * available there. */ class RelayAPIClient( baseUrl: String, private val platform: String, private val clientVersion: String, ) { private val baseUrl: String = baseUrl.trimEnd('/') private val httpClient: HttpClient = HttpClient.newBuilder().build() private val json = Json { ignoreUnknownKeys = true } // MARK: - GET /registration/challenge suspend fun fetchChallenge(): RegistrationChallenge { val request = HttpRequest.newBuilder(URI.create("$baseUrl/registration/challenge")) .header("Accept", "application/json") .GET() .build() return json.decodeFromString(RegistrationChallenge.serializer(), performRequest(request)) } // MARK: - POST /devices /** * Registers a new device. Fetches a challenge nonce first (§4.1), computes the two-level * HMAC response (§3.3) via [ChallengeResponse], then calls `POST /devices` (§4.2). */ suspend fun registerDevice(name: String): RegisterResponse { val challenge = fetchChallenge() val challengeResponse = ChallengeResponse.compute( masterKeyB64Url = RelayMasterKey.base64Url, nonce = challenge.nonce, version = challenge.version, serverDate = challenge.serverDate, platform = platform, clientVersion = clientVersion, ) val body = buildJsonObject { put("device_name", name) put("platform", platform) put("client_version", clientVersion) put("challenge_nonce", challenge.nonce) put("challenge_response", challengeResponse) } val request = HttpRequest.newBuilder(URI.create("$baseUrl/devices")) .header("Content-Type", "application/json") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build() return json.decodeFromString(RegisterResponse.serializer(), performRequest(request)) } // MARK: - DELETE /devices suspend fun deregisterDevice(sendToken: String) { val request = HttpRequest.newBuilder(URI.create("$baseUrl/devices")) .header("Authorization", "Bearer $sendToken") .DELETE() .build() performRequest(request) } // MARK: - POST /messages suspend fun sendMessage(msg: OutboundRelayMessage, sendToken: String): SendResponse { val body = buildJsonObject { put("message_id", msg.messageId) put("stream_id", msg.streamId) put("sender_device_id", msg.senderDeviceId) putJsonArrayOfStrings("recipient_inbox_ids", msg.recipientInboxIds) put("ciphertext", msg.ciphertext) put("cipher_version", msg.cipherVersion) msg.encryptedIdentifier?.let { put("encrypted_identifier", it) } msg.ttlSeconds?.let { put("ttl_seconds", it) } } val request = HttpRequest.newBuilder(URI.create("$baseUrl/messages")) .header("Content-Type", "application/json") .header("Authorization", "Bearer $sendToken") .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build() return json.decodeFromString(SendResponse.serializer(), performRequest(request)) } // MARK: - GET /messages suspend fun pollMessages( recvToken: String, inboxId: String, after: String?, limit: Int, waitMs: Int, ): PollResponse { val query = buildString { append("inbox_id=").append(urlEncode(inboxId)) append("&limit=").append(limit) append("&wait_ms=").append(waitMs) if (after != null) append("&after=").append(urlEncode(after)) } val request = HttpRequest.newBuilder(URI.create("$baseUrl/messages?$query")) .header("Authorization", "Bearer $recvToken") // Long-poll timeout: waitMs/1000 + 15 seconds, matching the Swift client. .timeout(java.time.Duration.ofMillis(waitMs.toLong() + 15_000)) .GET() .build() return json.decodeFromString(PollResponse.serializer(), performRequest(request)) } // MARK: - POST /messages/ack suspend fun ackMessages(recvToken: String, inboxId: String, messageIds: List) { val body = buildJsonObject { put("inbox_id", inboxId) putJsonArrayOfStrings("message_ids", messageIds) } val request = HttpRequest.newBuilder(URI.create("$baseUrl/messages/ack")) .header("Content-Type", "application/json") .header("Authorization", "Bearer $recvToken") .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build() performRequest(request) } // MARK: - GET /limits suspend fun fetchLimits(): ServerLimits { val request = HttpRequest.newBuilder(URI.create("$baseUrl/limits")) .header("Accept", "application/json") .GET() .build() return json.decodeFromString(ServerLimits.serializer(), performRequest(request)) } // MARK: - GET /health suspend fun healthCheck(): Boolean { val request = HttpRequest.newBuilder(URI.create("$baseUrl/health")) .timeout(java.time.Duration.ofSeconds(10)) .GET() .build() return try { performRequest(request) true } catch (e: RelayError.ServerError) { false } } // MARK: - Internal request performer private suspend fun performRequest(request: HttpRequest): String = withContext(Dispatchers.IO) { val response = try { httpClient.send(request, HttpResponse.BodyHandlers.ofString()) } catch (e: Exception) { throw RelayError.NetworkError(e) } when (response.statusCode()) { in 200..299 -> response.body() 401 -> { val code = try { json.decodeFromString(RelayApiErrorEnvelope.serializer(), response.body()).error.code } catch (e: Exception) { null } throw when (code) { "token_expired" -> RelayError.TokenExpired "challenge_failed", "challenge_required" -> RelayError.ChallengeFailed else -> RelayError.Unauthorized } } 404 -> throw RelayError.NotFound 429 -> { val retryAfter = response.headers().firstValue("Retry-After").orElse(null)?.toIntOrNull() ?: 60 throw RelayError.RateLimited(retryAfterSeconds = retryAfter) } else -> throw RelayError.ServerError(response.statusCode(), response.body()) } } private fun urlEncode(s: String): String = java.net.URLEncoder.encode(s, Charsets.UTF_8).replace("+", "%20") } private fun kotlinx.serialization.json.JsonObjectBuilder.putJsonArrayOfStrings(key: String, values: List) { put(key, kotlinx.serialization.json.JsonArray(values.map { JsonPrimitive(it) })) }