feat(waiter): encrypted, key-pinned connection to the venue server (native app)

- Android: MainActivity installs a WebViewClient whose onReceivedSslError
  proceeds only when the server's SPKI SHA-256 is in TrustStore (pins of all
  paired venues, SharedPreferences); XeniaTlsPlugin lets JS set that list.
  Spike showed this one callback covers fetch/XHR, images AND WebSockets.
  Pins are per key, not per address: IP changes, renewals and expiry never
  need action; a different key is always refused.
- Pairing: QR key (k=) must equal the server's advertised pin, else refused;
  typed addresses trust the advertised key on first use. Then the venue moves
  to https://<ip>:<tls.port> (stays on HTTP if that port isn't reachable yet).
- upgradeToTls(): on every start, a plain-HTTP venue moves onto TLS once the
  server offers it (never accepting a key different from the stored one).
- main.jsx pushes the venues' pins to native before the first request.
- Rediscovery made proactive: checks the saved address at start and every
  minute while the live connection is down, instead of waiting for requests
  to an unanswered IP to time out (minutes). HTTPS probes get 5s: the first
  TLS connection in a fresh process takes ~2s (measured).

E2E on the emulator vs an isolated stack: wrong-key QR refused; pairing on
TLS; tables + wss live; impostor server with another key refused natively;
HTTP venue upgraded on start; server cert renewed (same key) - app keeps
working without re-pairing; rediscovery over TLS (11s). Step 5 HTTP
rediscovery (now 2.7s/4.8s) and cold-start login tests, and web modes, pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:41:25 +03:00
co-authored by Claude Opus 5.5
parent 7811bf5dcb
commit 18f13f12dd
10 changed files with 263 additions and 26 deletions
@@ -1,5 +1,36 @@
package gr.bonamin.xenia;
import android.net.http.SslError;
import android.os.Bundle;
import android.util.Log;
import android.webkit.SslErrorHandler;
import android.webkit.WebView;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.BridgeWebViewClient;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
private static final String TAG = "XeniaTLS";
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(XeniaTlsPlugin.class);
super.onCreate(savedInstanceState);
// Venue servers present a self-signed certificate on https://<ip>:8443.
// Accept it only if its public key is pinned (TrustStore) — this callback
// covers fetch/XHR, images AND WebSockets, and the WebView remembers the
// decision for that host for the rest of the session.
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
String pin = TrustStore.spkiSha256(error.getCertificate());
if (pin != null && TrustStore.get(getApplicationContext()).contains(pin)) {
handler.proceed();
} else {
Log.w(TAG, "Refused TLS connection to " + error.getUrl() + " (key " + pin + " not pinned)");
handler.cancel();
}
}
});
}
}
@@ -0,0 +1,67 @@
package gr.bonamin.xenia;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.http.SslCertificate;
import android.os.Build;
import android.os.Bundle;
import android.util.Base64;
import java.io.ByteArrayInputStream;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* Public-key pins of every paired venue server (plan step 7).
*
* Venue servers use self-signed certificates, so the WebView always reports an
* SSL error for them. We accept such a connection only when the server's public
* key (SHA-256 of its SubjectPublicKeyInfo, base64) belongs to a venue this
* phone paired with. Pins are per key, not per address: IP changes, certificate
* renewals and expiry never break the connection; a different key always does.
* The JS side keeps the set in sync through XeniaTlsPlugin.
*/
final class TrustStore {
private static final String PREFS = "xenia_tls";
private static final String KEY = "trusted_spki_sha256";
private static Set<String> cache;
private TrustStore() {}
static synchronized Set<String> get(Context ctx) {
if (cache == null) {
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
cache = new HashSet<>(prefs.getStringSet(KEY, Collections.emptySet()));
}
return cache;
}
static synchronized void set(Context ctx, Set<String> pins) {
cache = new HashSet<>(pins);
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY, cache).apply();
}
/** Base64 SHA-256 of the certificate's SubjectPublicKeyInfo, or null if unreadable. */
static String spkiSha256(SslCertificate sslCert) {
try {
X509Certificate x509;
if (Build.VERSION.SDK_INT >= 29) {
x509 = sslCert.getX509Certificate();
} else {
// API 24-28: the DER bytes are only reachable through the saved state
Bundle state = SslCertificate.saveState(sslCert);
byte[] der = state.getByteArray("x509-certificate");
x509 = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(der));
}
if (x509 == null) return null;
byte[] hash = MessageDigest.getInstance("SHA-256").digest(x509.getPublicKey().getEncoded());
return Base64.encodeToString(hash, Base64.NO_WRAP);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,34 @@
package gr.bonamin.xenia;
import com.getcapacitor.JSArray;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONException;
/** JS → native: the set of venue-server key pins the WebView may trust (see TrustStore). */
@CapacitorPlugin(name = "XeniaTls")
public class XeniaTlsPlugin extends Plugin {
@PluginMethod
public void setTrustedKeys(PluginCall call) {
JSArray keys = call.getArray("keys");
Set<String> pins = new HashSet<>();
try {
if (keys != null) {
for (int i = 0; i < keys.length(); i++) {
String k = keys.getString(i);
if (k != null && !k.isEmpty()) pins.add(k);
}
}
} catch (JSONException e) {
call.reject("keys must be an array of strings");
return;
}
TrustStore.set(getContext(), pins);
call.resolve();
}
}