Add native TLS for Android love.system and desktop gen1tls.

Expose a non-blocking TLS socket API to mods (WSS clients) without bundling
any game-specific multiworld content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Solidus Snake
2026-08-10 17:43:53 -04:00
parent 4d03a38067
commit a9767e5df1
15 changed files with 1267 additions and 0 deletions
@@ -23,6 +23,7 @@
#ifdef LOVE_ANDROID
#include <cerrno>
#include <cstring>
#include <unordered_map>
#include <SDL.h>
@@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result;
}
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
* missing method answers like a platform without TLS instead of aborting.
*/
static jclass tlsActivityClass(JNIEnv *env)
{
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return nullptr;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
return activity;
}
int tlsOpen(const char *host, int port)
{
if (host == nullptr)
return -1;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jstring jhost = env->NewStringUTF(host);
jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port);
env->DeleteLocalRef(jhost);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsStatus(int handle)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jint result = env->CallStaticIntMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsSend(int handle, const char *data, int length)
{
if (data == nullptr || length <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jbyteArray payload = env->NewByteArray((jsize) length);
if (payload == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data);
jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload);
env->DeleteLocalRef(payload);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsReceive(int handle, char *buf, int max)
{
if (buf == nullptr || max <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max);
env->DeleteLocalRef(activity);
if (result == nullptr)
return 0;
jbyteArray bytes = (jbyteArray) result;
jsize length = env->GetArrayLength(bytes);
if (length > max)
length = max;
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf);
env->DeleteLocalRef(result);
return (int) length;
}
bool tlsError(int handle, char *buf, int max)
{
if (buf == nullptr || max <= 0)
return false;
buf[0] = '\0';
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
if (result == nullptr)
return false;
jstring text = (jstring) result;
const char *utf = env->GetStringUTFChars(text, nullptr);
if (utf != nullptr)
{
strncpy(buf, utf, (size_t) max - 1);
buf[max - 1] = '\0';
env->ReleaseStringUTFChars(text, utf);
}
env->DeleteLocalRef(result);
return buf[0] != '\0';
}
void tlsClose(int handle)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return;
jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return;
}
env->CallStaticVoidMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
}
/*
* Helper functions for the filesystem module
*/
@@ -98,6 +98,27 @@ bool restartApp();
**/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
* unreachable -- and an Archipelago room hosted on archipelago.gg accepts a
* plain connection only to drop it. The platform has both a TLS stack and the
* system trust store, so this borrows them rather than vendoring mbedTLS.
*
* tlsOpen returns a handle immediately and connects on its own thread: poll
* tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that
* does not exist. Bytes given to tlsSend before the handshake finishes are
* queued rather than refused. tlsReceive fills buf and returns how much it
* took, 0 when nothing is waiting. A closed connection keeps both its reason
* (tlsError) and whatever arrived before it closed until tlsClose.
**/
int tlsOpen(const char *host, int port);
int tlsStatus(int handle);
int tlsSend(int handle, const char *data, int length);
int tlsReceive(int handle, char *buf, int max);
bool tlsError(int handle, char *buf, int max);
void tlsClose(int handle);
/*
* Helper functions for the filesystem module
*/
@@ -244,6 +244,72 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif
}
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
return love::android::tlsOpen(host, port);
#else
LOVE_UNUSED(host);
LOVE_UNUSED(port);
return -1;
#endif
}
int System::tlsStatus(int handle) const
{
#ifdef LOVE_ANDROID
return love::android::tlsStatus(handle);
#else
LOVE_UNUSED(handle);
return -1;
#endif
}
int System::tlsSend(int handle, const char *data, int length) const
{
#ifdef LOVE_ANDROID
return love::android::tlsSend(handle, data, length);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(data);
LOVE_UNUSED(length);
return -1;
#endif
}
int System::tlsReceive(int handle, char *buf, int max) const
{
#ifdef LOVE_ANDROID
return love::android::tlsReceive(handle, buf, max);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(buf);
LOVE_UNUSED(max);
return -1;
#endif
}
bool System::tlsError(int handle, char *buf, int max) const
{
#ifdef LOVE_ANDROID
return love::android::tlsError(handle, buf, max);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(buf);
LOVE_UNUSED(max);
return false;
#endif
}
void System::tlsClose(int handle) const
{
#ifdef LOVE_ANDROID
love::android::tlsClose(handle);
#else
LOVE_UNUSED(handle);
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -149,6 +149,20 @@ public:
virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const;
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
* tlsOpen returns a handle and connects on its own thread, tlsStatus
* reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent
* before the handshake completes are queued rather than refused.
**/
virtual int tlsOpen(const char *host, int port) const;
virtual int tlsStatus(int handle) const;
virtual int tlsSend(int handle, const char *data, int length) const;
virtual int tlsReceive(int handle, char *buf, int max) const;
virtual bool tlsError(int handle, char *buf, int max) const;
virtual void tlsClose(int handle) const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -139,6 +139,79 @@ int w_hasBackgroundMusic(lua_State *L)
return 1;
}
/*
* TLS sockets. Deliberately a handle-and-poll API rather than an object:
* the caller is a per-frame pump that must never block, and everything with
* a thread behind it lives on the Java side.
*/
int w_tlsOpen(lua_State *L)
{
const char *host = luaL_checkstring(L, 1);
int port = (int) luaL_checknumber(L, 2);
lua_pushnumber(L, instance()->tlsOpen(host, port));
return 1;
}
int w_tlsStatus(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
lua_pushnumber(L, instance()->tlsStatus(handle));
return 1;
}
int w_tlsSend(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
size_t length = 0;
const char *data = luaL_checklstring(L, 2, &length);
lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length));
return 1;
}
int w_tlsReceive(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
int max = (int) luaL_optnumber(L, 2, 8192);
if (max <= 0)
{
lua_pushliteral(L, "");
return 1;
}
// A frame's worth of a busy room, on the C stack rather than the heap:
// this runs every frame and an allocation per poll is not worth it.
if (max > 65536)
max = 65536;
char buf[65536];
int got = instance()->tlsReceive(handle, buf, max);
if (got < 0)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, buf, (size_t) got);
return 1;
}
int w_tlsError(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
char buf[512];
if (!instance()->tlsError(handle, buf, (int) sizeof(buf)))
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, buf);
return 1;
}
int w_tlsClose(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
instance()->tlsClose(handle);
return 0;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -153,6 +226,12 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
{ "tlsReceive", w_tlsReceive },
{ "tlsError", w_tlsError },
{ "tlsClose", w_tlsClose },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -606,6 +606,45 @@ public class GameActivity extends SDLActivity {
* The body lands in a .part file and is renamed only once complete, so a
* dropped connection can never leave a half file the caller trusts.
*/
/**
* TLS client sockets, exposed as love.system.tls* and used by the
* Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without
* these a hosted room -- every one of which is TLS-only -- is unreachable
* from the game. The work is in TlsSocket; these are the static entry
* points, because the JNI side resolves methods on the activity's own
* class (see love/src/common/android.cpp) and cannot see other classes
* from a worker thread.
*/
@Keep
public static int tlsOpen(String host, int port) {
return TlsSocket.open(host, port);
}
@Keep
public static int tlsStatus(int handle) {
return TlsSocket.status(handle);
}
@Keep
public static int tlsSend(int handle, byte[] data) {
return TlsSocket.send(handle, data);
}
@Keep
public static byte[] tlsReceive(int handle, int max) {
return TlsSocket.receive(handle, max);
}
@Keep
public static String tlsError(int handle) {
return TlsSocket.error(handle);
}
@Keep
public static void tlsClose(int handle) {
TlsSocket.close(handle);
}
@Keep
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
if (url == null || destPath == null) return false;
@@ -0,0 +1,310 @@
package org.love2d.android;
import android.util.Log;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
/**
* A TLS client socket that Lua can drive without ever blocking a frame.
*
* WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing
* else, so wss:// was simply unreachable from the game -- and every room hosted
* on archipelago.gg is TLS-only, accepting a plain connection just long enough
* to drop it. The alternative was vendoring mbedTLS into the NDK build and
* carrying a CA bundle in the APK; the platform already has both a TLS stack
* and the system trust store, so this asks Android instead.
*
* THE CONTRACT. Callers get an int handle and poll it. open() returns
* immediately and the connect and handshake happen on their own thread, so a
* slow or unreachable host costs nothing on the game thread -- which matters
* more here than it did for httpDownload, since that runs on a worker and this
* is serviced from the frame loop. Bytes handed to send() before the handshake
* finishes are queued, not refused, so a caller can write its request the
* moment it has a handle and never think about readiness again.
*
* Reads are drained by a thread into a chunk queue and handed over a copy at a
* time; a caller that stops polling stops the connection rather than growing
* the heap without limit.
*/
final class TlsSocket {
static final int STATUS_CONNECTING = 0;
static final int STATUS_OPEN = 1;
static final int STATUS_CLOSED = 2;
private static final int CONNECT_TIMEOUT_MS = 15000;
private static final int READ_CHUNK = 16384;
/** Roughly a second of a very chatty room; past this the reader is gone. */
private static final int MAX_BUFFERED = 4 * 1024 * 1024;
private static final ConcurrentHashMap<Integer, TlsSocket> LIVE =
new ConcurrentHashMap<Integer, TlsSocket>();
private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1);
private final String host;
private final int port;
private final int handle;
private volatile int status = STATUS_CONNECTING;
private volatile String error = null;
private volatile boolean closing = false;
private volatile SSLSocket socket = null;
private final Object inLock = new Object();
private final ArrayDeque<byte[]> inChunks = new ArrayDeque<byte[]>();
private int inHeadOffset = 0;
private int inAvailable = 0;
private final Object outLock = new Object();
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
private TlsSocket(String host, int port, int handle) {
this.host = host;
this.port = port;
this.handle = handle;
}
// ---------------------------------------------------------------- API
static int open(String host, int port) {
if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1;
final int handle = NEXT_HANDLE.getAndIncrement();
final TlsSocket self = new TlsSocket(host, port, handle);
LIVE.put(Integer.valueOf(handle), self);
Thread dialer = new Thread(new Runnable() {
@Override public void run() { self.dial(); }
}, "tls-dial-" + handle);
dialer.setDaemon(true);
dialer.start();
return handle;
}
static int status(int handle) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
return self == null ? -1 : self.status;
}
static String error(int handle) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
return self == null ? null : self.error;
}
static int send(int handle, byte[] data) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
if (self == null || data == null) return -1;
if (self.status == STATUS_CLOSED) return -1;
if (data.length == 0) return 0;
synchronized (self.outLock) {
self.outChunks.add(data);
self.outLock.notifyAll();
}
return data.length;
}
static byte[] receive(int handle, int max) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
if (self == null || max <= 0) return null;
return self.take(max);
}
static void close(int handle) {
TlsSocket self = LIVE.remove(Integer.valueOf(handle));
if (self != null) self.shutdown(null);
}
// ------------------------------------------------------------ internals
private void dial() {
Socket plain = null;
try {
plain = new Socket();
plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
plain.setTcpNoDelay(true);
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true);
// Wrapping an already-connected socket skips the SNI and hostname
// checking that createSocket(host, port) would have done for us, and
// a shared address like archipelago.gg answers with the wrong
// certificate without the name in the hello. Both are set through
// SSLParameters where the platform has it, with the verifier below
// as the floor for anything older.
boolean verifiedByPlatform = false;
try {
SSLParameters params = ssl.getSSLParameters();
params.setEndpointIdentificationAlgorithm("HTTPS");
List<SNIServerName> names = new ArrayList<SNIServerName>(1);
names.add(new SNIHostName(host));
params.setServerNames(names);
ssl.setSSLParameters(params);
verifiedByPlatform = true;
} catch (Throwable ignored) {
// Older platform: handled after the handshake instead.
}
enableModernProtocols(ssl);
ssl.startHandshake();
if (!verifiedByPlatform
&& !HttpsURLConnection.getDefaultHostnameVerifier()
.verify(host, ssl.getSession())) {
throw new java.io.IOException(
"certificate does not match " + host);
}
socket = ssl;
if (closing) { shutdown(null); return; }
status = STATUS_OPEN;
Thread writer = new Thread(new Runnable() {
@Override public void run() { pumpOut(); }
}, "tls-write-" + handle);
writer.setDaemon(true);
writer.start();
pumpIn();
} catch (Throwable t) {
shutdown(describe(t));
if (plain != null) {
try { plain.close(); } catch (Throwable ignored) {}
}
}
}
/**
* minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every
* modern server refuses everything older, so switch on whatever the
* platform has rather than leaving an old device negotiating TLS 1.0.
*/
private static void enableModernProtocols(SSLSocket ssl) {
try {
List<String> wanted = new ArrayList<String>(3);
for (String supported : ssl.getSupportedProtocols()) {
if (supported.startsWith("TLSv1.1")
|| supported.startsWith("TLSv1.2")
|| supported.startsWith("TLSv1.3")) {
wanted.add(supported);
}
}
if (!wanted.isEmpty()) {
ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()]));
}
} catch (Throwable ignored) {
}
}
private void pumpIn() {
try {
InputStream in = socket.getInputStream();
byte[] buf = new byte[READ_CHUNK];
while (!closing) {
int n = in.read(buf);
if (n < 0) break;
if (n == 0) continue;
byte[] chunk = new byte[n];
System.arraycopy(buf, 0, chunk, 0, n);
synchronized (inLock) {
if (inAvailable + n > MAX_BUFFERED) {
throw new java.io.IOException("read buffer overflow");
}
inChunks.add(chunk);
inAvailable += n;
}
}
shutdown(null);
} catch (Throwable t) {
shutdown(describe(t));
}
}
private void pumpOut() {
try {
OutputStream out = socket.getOutputStream();
while (true) {
byte[] chunk;
synchronized (outLock) {
while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) {
outLock.wait();
}
if (closing || status == STATUS_CLOSED) return;
chunk = outChunks.poll();
}
if (chunk != null) {
out.write(chunk);
out.flush();
}
}
} catch (Throwable t) {
shutdown(describe(t));
}
}
private byte[] take(int max) {
synchronized (inLock) {
if (inAvailable <= 0) return null;
int want = Math.min(max, inAvailable);
byte[] out = new byte[want];
int filled = 0;
while (filled < want) {
byte[] head = inChunks.peek();
if (head == null) break;
int have = head.length - inHeadOffset;
int take = Math.min(have, want - filled);
System.arraycopy(head, inHeadOffset, out, filled, take);
filled += take;
inHeadOffset += take;
if (inHeadOffset >= head.length) {
inChunks.poll();
inHeadOffset = 0;
}
}
inAvailable -= filled;
if (filled == want) return out;
byte[] short_ = new byte[filled];
System.arraycopy(out, 0, short_, 0, filled);
return short_;
}
}
/**
* The handle stays registered until the caller closes it, so why the
* connection ended and whatever arrived before it did are both still
* readable. Dropping it here instead would turn a server that states its
* refusal and hangs up into an unknown handle, which is the one failure a
* player most needs the reason for.
*/
private void shutdown(String why) {
if (why != null && error == null) error = why;
closing = true;
status = STATUS_CLOSED;
synchronized (outLock) { outLock.notifyAll(); }
SSLSocket s = socket;
socket = null;
if (s != null) {
try { s.close(); } catch (Throwable ignored) {}
}
if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why);
}
private static String describe(Throwable t) {
String msg = t.getMessage();
String name = t.getClass().getSimpleName();
if (msg == null || msg.length() == 0) return name;
return name + ": " + msg;
}
}