diff --git a/.gitignore b/.gitignore index 4f333b48..e330dfb8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ __pycache__/ # QA campaign notes stay local (owner, 2026-09-15) docs/qa/ + +# Release keystore and its password (backed up by Kopia, never committed) +signing/ diff --git a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorAdventureLogNetwork.kt b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorAdventureLogNetwork.kt index 1cdfe5f4..91fba9c7 100644 --- a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorAdventureLogNetwork.kt +++ b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorAdventureLogNetwork.kt @@ -21,6 +21,14 @@ import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.plugin import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -94,11 +102,39 @@ class KtorAdventureLogNetwork( private val rejections = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val sessionRejections: Flow = rejections.asSharedFlow() + private val csrfToken = newCsrfToken() + + /** Null until the first request to a server finds out; reset whenever the server changes. */ + private var behindWebProxy: Boolean? = null + private val probeLock = Mutex() + init { // One place sees every answer, so no screen has to recognise an ended session on its own. // Most of them could not: /api/locations/ answers an anonymous caller 200 with an empty // list, and Places told a signed-out user "No places yet" over 22 of them (measured). adventurelogClient.plugin(HttpSend).intercept { request -> + // The session comes from the request's own header, not from sessionToken: sign-out + // sends its DELETE in the background after clearSession() has already emptied it. + val requestSession = request.headers[SESSION_TOKEN_HEADER] + val toOwnServer = baseUrl?.let { Url(it).origin() == request.url.build().origin() } == true + if (requestSession != null || toOwnServer) { + val origin = request.url.build().origin() + val proxied = behindWebProxy ?: probeLock.withLock { + behindWebProxy ?: run { + val probe = HttpRequestBuilder().apply { + url("$origin$CSRF_PROBE_PATH") + header("X-Is-Mobile", "true") + } + val answer = execute(probe).response + (answer.contentType()?.match(ContentType.Text.Html) == true).also { + logger.d { "Server at $origin: ${if (it) "behind the web frontend's proxy" else "Django directly"}" } + behindWebProxy = it + } + } + } + if (proxied) request.attachProxiedSession(requestSession) + else request.attachDjangoSession(requestSession, csrfToken) + } val call = execute(request) val signingOut = request.method == HttpMethod.Delete && call.request.url.encodedPath.endsWith(SESSION_PATH) if (call.response.status == HttpStatusCode.Unauthorized && request.headers.contains(SESSION_TOKEN_HEADER) && !signingOut) { @@ -240,6 +276,7 @@ class KtorAdventureLogNetwork( sessionToken: String? ) { this.baseUrl = serverUrl.trimEnd('/') + behindWebProxy = null this.sessionToken = sessionToken logger.d { "Network initialized from existing session - BaseURL: ${this.baseUrl}, " + @@ -262,6 +299,7 @@ class KtorAdventureLogNetwork( override fun clearSession() { sessionToken = null baseUrl = null + behindWebProxy = null logger.d { "Network session cleared - all tokens and base URL reset" } } @@ -271,6 +309,7 @@ class KtorAdventureLogNetwork( password: String ): UserDetailsDTO { baseUrl = url.trimEnd('/') + behindWebProxy = null // Initialize the session info before calling login // The authDataSource will use the sessionProvider to get the baseUrl diff --git a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorConfig.kt b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorConfig.kt index ab4999c5..6e1998ae 100644 --- a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorConfig.kt +++ b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/KtorConfig.kt @@ -1,7 +1,10 @@ package com.desarrollodroide.adventurelog.core.network.ktor +import io.ktor.client.request.HttpRequestBuilder import io.ktor.http.HeadersBuilder import io.ktor.http.HttpHeaders +import io.ktor.http.Url +import io.ktor.http.encodedPath import kotlinx.serialization.json.Json data class SessionInfo( @@ -11,9 +14,73 @@ data class SessionInfo( internal const val SESSION_TOKEN_HEADER = "X-Session-Token" -/** allauth's browser session: GET is the session, DELETE is signing out. */ +/** + * allauth's app client, not the browser one: its login hands the session back in the body, where + * the web frontend's proxy can't strip it (it drops every Set-Cookie). The token is a Django + * session key, sent like any other session. + */ +internal const val LOGIN_PATH = "/auth/app/v1/auth/login" + +/** + * allauth's browser session: GET is the session, DELETE is signing out. Not the app client's: its + * DELETE answers 401 and leaves the session alive (measured on 0.13.1, direct and proxied). + */ internal const val SESSION_PATH = "/auth/browser/v1/auth/session" +/** + * Django answers it with JSON. On the all-in-one install the web frontend takes it and answers + * its own HTML 404 - that is how the app knows its requests go through the frontend's proxy. + */ +internal const val CSRF_PROBE_PATH = "/csrf/" + +internal const val CSRF_HEADER = "X-CSRFToken" + +private val CSRF_ALPHABET = ('A'..'Z') + ('a'..'z') + ('0'..'9') + +/** + * A CSRF token the app makes up itself. Django's check is double-submit - the `csrftoken` cookie + * must equal the `X-CSRFToken` header - so it needs no round trip to the server, only the right + * shape: 32 characters from [A-Za-z0-9], or Django rejects it as malformed. + */ +internal fun newCsrfToken(): String = (1..32).map { CSRF_ALPHABET.random() }.joinToString("") + +internal fun Url.origin(): String = + if (port == protocol.defaultPort) "${protocol.name}://$host" else "${protocol.name}://$host:$port" + +/** + * What a Django session needs since server 0.13.1, which stopped reading [SESSION_TOKEN_HEADER] + * and checks CSRF on login: the session as a `sessionid` cookie, a csrftoken pair, and a + * same-origin Referer (required on every unsafe request over HTTPS). [SESSION_TOKEN_HEADER] stays + * on the request: older servers still read it, and it marks a request that carried a session. + */ +internal fun HttpRequestBuilder.attachDjangoSession(sessionToken: String?, csrfToken: String) { + val cookies = listOfNotNull("csrftoken=$csrfToken", sessionToken?.let { "sessionid=$it" }) + headers.append(HttpHeaders.Cookie, cookies.joinToString("; ")) + headers.append(CSRF_HEADER, csrfToken) + if (!headers.contains(HttpHeaders.Referrer)) { + headers.append(HttpHeaders.Referrer, url.build().origin()) + } +} + +/** + * The same session through the web frontend's proxy (upstream's all-in-one install): the proxy + * fetches its own CSRF token and adds it, so a second X-CSRFToken from the app turns into two + * values and Django refuses it - only the session cookie goes. And the frontend answers a path + * ending in "/" with a 308 to the same path without it, which a POST does not survive; the proxy + * puts the slash back before calling Django. SvelteKit also refuses a form post (a photo upload) + * whose Origin is not the site: 403 "Cross-site POST form submissions are forbidden". + */ +internal fun HttpRequestBuilder.attachProxiedSession(sessionToken: String?) { + sessionToken?.let { headers.append(HttpHeaders.Cookie, "sessionid=$it") } + if (!headers.contains(HttpHeaders.Origin)) { + headers.append(HttpHeaders.Origin, url.build().origin()) + } + val path = url.encodedPath + if (path.length > 1 && path.endsWith("/")) { + url.encodedPath = path.trimEnd('/') + } +} + internal fun HeadersBuilder.commonHeaders(sessionToken: String?) { append(HttpHeaders.Accept, "application/json") append("X-Is-Mobile", "true") diff --git a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/api/KtorAuthApi.kt b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/api/KtorAuthApi.kt index 1abaa03b..db86f1d3 100644 --- a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/api/KtorAuthApi.kt +++ b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/api/KtorAuthApi.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.desarrollodroide.adventurelog.core.network.api.AuthApi import com.desarrollodroide.adventurelog.core.network.ktor.HttpException import com.desarrollodroide.adventurelog.core.network.ktor.SessionInfo +import com.desarrollodroide.adventurelog.core.network.ktor.LOGIN_PATH import com.desarrollodroide.adventurelog.core.network.ktor.SESSION_PATH import com.desarrollodroide.adventurelog.core.network.ktor.commonHeaders import com.desarrollodroide.adventurelog.core.network.ktor.defaultJson @@ -37,7 +38,7 @@ class KtorAuthApi( password: String ): UserDetailsDTO { val sessionInfo = sessionProvider() - val loginUrl = "${sessionInfo.baseUrl}/auth/browser/v1/auth/login" + val loginUrl = "${sessionInfo.baseUrl}$LOGIN_PATH" logger.d { "Login URL: $loginUrl" } val response = httpClient.post(loginUrl) { @@ -56,22 +57,17 @@ class KtorAuthApi( logger.d { "Login response status: ${response.status}" } if (response.status.isSuccess()) { - val cookies = response.headers.getAll("Set-Cookie") ?: emptyList() + val responseBody = response.body() + val loginResponse = json.decodeFromString(responseBody) - var sessionToken: String? = null - for (cookie in cookies) { - if (cookie.contains("sessionid=")) { - val sessionidPattern = Regex("sessionid=([^;]+)") - val matchResult = sessionidPattern.find(cookie) - sessionToken = matchResult?.groupValues?.get(1) - } - } + // The app client returns the session in the body, so it survives the web frontend's + // proxy, which strips every Set-Cookie. The cookie is only a fallback. + val sessionToken = loginResponse.meta.sessionToken + ?: response.headers.getAll("Set-Cookie").orEmpty() + .firstNotNullOfOrNull { Regex("sessionid=([^;]+)").find(it)?.groupValues?.get(1) } if (sessionToken == null) { - // Pointing the app at the web frontend instead of the backend lands here: the - // SvelteKit proxy strips set-cookie from everything it forwards, so login answers - // 200 with no session at all. - logger.w { "No session token found in cookies - is the server URL the backend?" } + logger.w { "Login answered ${response.status.value} with no session token" } } else { // Never log the token itself - it grants full account access. logger.d { "Session token received (${sessionToken.length} chars)" } @@ -79,10 +75,6 @@ class KtorAuthApi( onSessionTokenReceived(sessionToken) } - val responseBody = response.body() - - val loginResponse = json.decodeFromString(responseBody) - logger.d { "Login successful for user: ${loginResponse.data.user.username}" } return UserDetailsDTO( diff --git a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/model/request/AuthRequests.kt b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/model/request/AuthRequests.kt index 9543123c..c8e5f6a8 100644 --- a/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/model/request/AuthRequests.kt +++ b/core/network/src/commonMain/kotlin/com/desarrollodroide/adventurelog/core/network/model/request/AuthRequests.kt @@ -29,7 +29,8 @@ data class LoginUserData( val display: String, @SerialName("has_usable_password") val hasUsablePassword: Boolean? = null, - val email: String, + /** The app client leaves it out when the account has none. */ + val email: String = "", val username: String, @SerialName("first_name") val firstName: String? = null, @@ -59,5 +60,8 @@ data class LoginMethod( @Serializable data class LoginMeta( @SerialName("is_authenticated") - val isAuthenticated: Boolean + val isAuthenticated: Boolean, + /** Only the app client sends it; it is the Django session key. */ + @SerialName("session_token") + val sessionToken: String? = null ) diff --git a/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/DjangoSessionHeadersTest.kt b/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/DjangoSessionHeadersTest.kt new file mode 100644 index 00000000..55d0f6e4 --- /dev/null +++ b/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/DjangoSessionHeadersTest.kt @@ -0,0 +1,178 @@ +package com.desarrollodroide.adventurelog.core.network.ktor + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Server 0.13.1 dropped the middleware that read X-Session-Token and checks CSRF on login, so a + * request only counts as signed in when it carries the session as a cookie, a matching csrftoken + * pair and a same-origin Referer. Measured on the NAS: without these, login answers 403 "CSRF + * cookie not set" and every other call is anonymous. + */ +class DjangoSessionHeadersTest { + + /** + * `proxied` answers the /csrf/ probe the way upstream's all-in-one install does (the web + * frontend's HTML 404); otherwise Django's JSON. Probes are counted, not recorded as requests. + */ + private class Harness( + status: HttpStatusCode = HttpStatusCode.OK, + proxied: Boolean = false, + body: String = "{}", + ) { + val requests = mutableListOf() + var probes = 0 + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val client = HttpClient(MockEngine { request -> + if (request.url.encodedPath == "/csrf/") { + probes++ + if (proxied) respond("", HttpStatusCode.NotFound, headersOf(HttpHeaders.ContentType, "text/html")) + else respond("""{"csrfToken":"x"}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + requests += request + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + } + }) { + install(ContentNegotiation) { json(defaultJson) } + } + val network = KtorAdventureLogNetwork(client, backgroundScope = scope) + + suspend fun settle() = withTimeout(5_000) { scope.coroutineContext[Job]!!.children.toList().joinAll() } + } + + private fun HttpRequestData.cookies(): Map = + headers[HttpHeaders.Cookie].orEmpty().split(';') + .map { it.trim() } + .filter { it.contains('=') } + .associate { it.substringBefore('=') to it.substringAfter('=') } + + private fun assertCsrfPair(request: HttpRequestData) { + val csrf = request.headers["X-CSRFToken"] + assertNotNull(csrf, "no X-CSRFToken header") + assertTrue(Regex("[A-Za-z0-9]{32}").matches(csrf), "Django rejects a token shaped like '$csrf'") + assertEquals(csrf, request.cookies()["csrftoken"], "the csrftoken cookie does not match the header") + } + + @Test + fun `a signed-in write carries the session cookie and a csrf pair and the server as Referer`() = runBlocking { + val h = Harness() + h.network.initializeFromSession(serverUrl = "https://qa.test:3447/", sessionToken = "session-token") + + h.client.post("https://qa.test:3447/api/locations/") { headers { commonHeaders("session-token") } } + + val request = h.requests.single() + assertEquals("session-token", request.cookies()["sessionid"]) + assertCsrfPair(request) + assertEquals("https://qa.test:3447", request.headers[HttpHeaders.Referrer]) + h.scope.cancel() + } + + @Test + fun `login carries a csrf pair and a Referer but no session`() = runBlocking { + val h = Harness(HttpStatusCode.Forbidden) + + val failure = runCatching { h.network.sendLogin("https://qa.test", "someone", "secret") }.exceptionOrNull() + + val request = h.requests.singleOrNull() ?: error("no login request was sent: $failure") + assertEquals("/auth/app/v1/auth/login", request.url.encodedPath) + assertCsrfPair(request) + assertNull(request.cookies()["sessionid"]) + assertEquals("https://qa.test", request.headers[HttpHeaders.Referrer]) + h.scope.cancel() + } + + @Test + fun `sign-out still sends the session after the local session is cleared`() = runBlocking { + val h = Harness(HttpStatusCode.Unauthorized) + h.network.initializeFromSession(serverUrl = "https://qa.test", sessionToken = "session-token") + + h.network.endServerSession() + h.network.clearSession() + h.settle() + + val request = h.requests.single() + assertEquals("session-token", request.cookies()["sessionid"]) + assertCsrfPair(request) + h.scope.cancel() + } + + @Test + fun `nothing of the session goes to another server`() = runBlocking { + val h = Harness() + h.network.initializeFromSession(serverUrl = "https://qa.test", sessionToken = "session-token") + + h.client.get("https://en.wikipedia.org/api/rest_v1/page/summary/Lima") + + val request = h.requests.single() + assertNull(request.headers[HttpHeaders.Cookie]) + assertNull(request.headers["X-CSRFToken"]) + h.scope.cancel() + } + + @Test + fun `login keeps the session the app client returns in the body`() = runBlocking { + // What allauth's app client answers: no Set-Cookie (the proxy would strip it anyway). + val login = """{"status":200,"data":{"user":{"id":7,"display":"playreview","username":"playreview"},""" + + """"methods":[{"method":"password","at":1.0,"username":"playreview"}]},""" + + """"meta":{"is_authenticated":true,"session_token":"token-from-body"}}""" + val h = Harness(body = login) + + h.network.sendLogin("https://qa.test", "playreview", "secret") + h.network.endServerSession() + h.settle() + + val signOut = h.requests.last() + assertEquals("token-from-body", signOut.cookies()["sessionid"]) + h.scope.cancel() + } + + @Test + fun `behind the web frontend proxy a write sends only the session and no trailing slash`() = runBlocking { + val h = Harness(proxied = true) + h.network.initializeFromSession(serverUrl = "https://qa.test", sessionToken = "session-token") + + h.client.post("https://qa.test/api/locations/") { headers { commonHeaders("session-token") } } + + val request = h.requests.single() + assertEquals("/api/locations", request.url.encodedPath, "the frontend answers a trailing slash with a 308 a POST does not survive") + assertEquals(mapOf("sessionid" to "session-token"), request.cookies(), "the proxy adds its own csrftoken") + assertNull(request.headers["X-CSRFToken"], "a second X-CSRFToken beside the proxy's makes Django refuse the request") + assertEquals("https://qa.test", request.headers[HttpHeaders.Origin], "SvelteKit refuses a form post from another origin") + h.scope.cancel() + } + + @Test + fun `the server is probed once and not on every request`() = runBlocking { + val h = Harness(proxied = true) + h.network.initializeFromSession(serverUrl = "https://qa.test", sessionToken = "session-token") + + repeat(3) { h.client.get("https://qa.test/api/collections/") { headers { commonHeaders("session-token") } } } + + assertEquals(1, h.probes) + assertEquals(3, h.requests.size) + h.scope.cancel() + } +} diff --git a/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/SignOutTest.kt b/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/SignOutTest.kt index 0432b0e4..7099b4bb 100644 --- a/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/SignOutTest.kt +++ b/core/network/src/commonTest/kotlin/com/desarrollodroide/adventurelog/core/network/ktor/SignOutTest.kt @@ -30,7 +30,11 @@ class SignOutTest { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // allauth answers a successful DELETE on the session with 401. val network = KtorAdventureLogNetwork( - HttpClient(MockEngine { request -> requests += request; respond("", HttpStatusCode.Unauthorized) }), + HttpClient(MockEngine { request -> + // The /csrf/ probe that tells a direct Django from the web frontend's proxy. + if (request.url.encodedPath != "/csrf/") requests += request + respond("", HttpStatusCode.Unauthorized) + }), backgroundScope = scope ) diff --git a/feature/ui/src/commonMain/kotlin/com/desarrollodroide/adventurelog/feature/ui/di/ImageLoaderModule.kt b/feature/ui/src/commonMain/kotlin/com/desarrollodroide/adventurelog/feature/ui/di/ImageLoaderModule.kt index 3888487b..2de8d4f8 100644 --- a/feature/ui/src/commonMain/kotlin/com/desarrollodroide/adventurelog/feature/ui/di/ImageLoaderModule.kt +++ b/feature/ui/src/commonMain/kotlin/com/desarrollodroide/adventurelog/feature/ui/di/ImageLoaderModule.kt @@ -9,6 +9,7 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory import io.ktor.client.HttpClient import io.ktor.client.request.HttpRequestPipeline import io.ktor.client.request.header +import io.ktor.http.HttpHeaders import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.koin.core.module.Module @@ -68,6 +69,8 @@ val imageLoaderModule = module { isSameOrigin(context.url.buildString(), session?.serverUrl) ) { context.header("X-Session-Token", token) + // Server 0.13.1 reads the session only from its cookie. GETs need no CSRF. + context.header(HttpHeaders.Cookie, "sessionid=$token") } } } diff --git a/scripts/qa/adapter.py b/scripts/qa/adapter.py index 8f0fe69f..f25425bb 100644 --- a/scripts/qa/adapter.py +++ b/scripts/qa/adapter.py @@ -29,6 +29,8 @@ - DELETE /auth/browser/v1/auth/session with X-Session-Token revokes that session (answers 401 by allauth's convention). The adapter's cached token then answers 401 and `request` logs in again. """ +import secrets +import string import json import os import re @@ -67,6 +69,24 @@ def account(key="A"): return creds[key] +CSRF = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) + + +def _django_session(req): + """What server 0.13.1 wants, as the app sends it: the session as a `sessionid` cookie (it no + longer reads X-Session-Token), a made-up csrftoken pair (double-submit, 32 alphanumerics) and a + same-origin Referer - login and every unsafe request answer 403 without them.""" + from urllib.parse import urlsplit + u = urlsplit(req.full_url) + cookies = [f"csrftoken={CSRF}"] + if req.has_header("X-session-token"): + cookies.append(f"sessionid={req.get_header('X-session-token')}") + req.add_header("Cookie", "; ".join(cookies)) + req.add_header("X-CSRFToken", CSRF) + if not req.has_header("Referer"): + req.add_header("Referer", f"{u.scheme}://{u.netloc}") + + def _raw(method, url, body=None, headers=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) @@ -76,6 +96,7 @@ def _raw(method, url, body=None, headers=None): req.add_header("Content-Type", "application/json") for k, v in (headers or {}).items(): req.add_header(k, v) + _django_session(req) try: with urllib.request.urlopen(req, timeout=30, context=ssl.create_default_context()) as res: return res.status, res.headers, res.read() @@ -160,6 +181,7 @@ def upload(path, fields, files, account="A"): req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") req.add_header("Accept", "application/json") req.add_header("X-Session-Token", token(account)) + _django_session(req) try: with urllib.request.urlopen(req, timeout=60, context=ssl.create_default_context()) as res: return res.status, _parse(res.read()) diff --git a/scripts/qa/add_account.py b/scripts/qa/add_account.py new file mode 100644 index 00000000..04a78ac3 --- /dev/null +++ b/scripts/qa/add_account.py @@ -0,0 +1,22 @@ +"""Adds or replaces one account in qa.credentials.json, asking for the password without echoing it. + + python3 scripts/qa/add_account.py R playreview + +The agent never writes this file (R11): the human runs this and types the password. +""" +import getpass +import json +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +PATH = os.path.join(ROOT, "qa.credentials.json") + +key, username = sys.argv[1], sys.argv[2] +password = getpass.getpass(f"Password for {username}: ") +creds = json.load(open(PATH)) if os.path.exists(PATH) else {} +creds[key] = {"username": username, "password": password} +fd = os.open(PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(fd, "w") as fh: + json.dump(creds, fh, indent=2) +print(f"{key} saved ({username}); accounts now: {', '.join(sorted(creds))}") diff --git a/scripts/release/create_signing_secrets.sh b/scripts/release/create_signing_secrets.sh new file mode 100755 index 00000000..ccebc585 --- /dev/null +++ b/scripts/release/create_signing_secrets.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Uploads the four CI signing secrets from the existing upload keystore (the one that signed the +# AAB in the Play draft). Copies the keystore into signing/ first - gitignored, inside the project +# so the Mac's Kopia backup picks it up - and refuses to upload unless its certificate matches the +# Play AAB's. The password is read from pass.txt and never printed. +set -euo pipefail + +REPO="DesarrolloAntonio/AdventureLog-Client" +PROJECT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +SOURCE_DIR="$PROJECT_DIR/../KeyStore" +SIGNING_DIR="$PROJECT_DIR/signing" +PLAY_AAB="$PROJECT_DIR/composeApp/release/composeApp-release.aab" +ALIAS="key0" + +mkdir -p "$SIGNING_DIR" +chmod 700 "$SIGNING_DIR" +cp -n "$SOURCE_DIR"/key_store.jks "$SOURCE_DIR"/pass.txt "$SOURCE_DIR"/private_key.pepk "$SIGNING_DIR"/ +chmod 600 "$SIGNING_DIR"/* +KEYSTORE="$SIGNING_DIR/key_store.jks" + +PASSWORD="$(head -n 1 "$SIGNING_DIR/pass.txt" | tr -d '\r\n')" + +KEY_SHA="$(keytool -list -v -keystore "$KEYSTORE" -alias "$ALIAS" -storepass "$PASSWORD" \ + | grep 'SHA256:' | head -n 1 | awk '{print $2}')" +AAB_SHA="$(keytool -printcert -jarfile "$PLAY_AAB" | grep 'SHA256:' | head -n 1 | awk '{print $2}')" +if [ -z "$KEY_SHA" ] || [ "$KEY_SHA" != "$AAB_SHA" ]; then + echo "The keystore's certificate does not match the Play AAB's - nothing uploaded." >&2 + echo " keystore: ${KEY_SHA:-}" >&2 + echo " Play AAB: $AAB_SHA" >&2 + exit 1 +fi +echo "Keystore matches the Play AAB ($KEY_SHA)." + +base64 -i "$KEYSTORE" | gh secret set KEYSTORE_BASE64 -R "$REPO" +printf '%s' "$PASSWORD" | gh secret set RELEASE_STORE_PASSWORD -R "$REPO" +printf '%s' "$PASSWORD" | gh secret set RELEASE_KEY_PASSWORD -R "$REPO" +printf '%s' "$ALIAS" | gh secret set RELEASE_KEY_ALIAS -R "$REPO" + +gh secret list -R "$REPO"