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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user