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();
}
}
+26 -16
View File
@@ -1,21 +1,25 @@
import { useEffect, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
import { canRediscover, rediscover } from '../native/autoRediscover'
import { canRediscover, rediscover, upgradeToTls } from '../native/autoRediscover'
const RETRY_WHILE_OFFLINE_MS = 2 * 60_000
const WATCHDOG_MS = 60_000
const RELOAD_DELAY_MS = 1500
/**
* Native app: when the venue server stops answering, look for it at a new
* address on the same network and reconnect automatically (plan step 5).
* Native app: keep the connection to the venue server healthy on its own
* (plan steps 5 and 7).
*
* Triggers: the connection is confirmed offline (logged in), or any request
* fails with a network error (login / offline screens). Retries every 2 minutes
* while offline. On success it shows a short notice and reloads into the new
* address — the offline queue lives in IndexedDB and syncs after the reload.
* - On start: move a plain-HTTP venue onto the encrypted endpoint, and check the
* saved address (1.5s probe) — a server that moved is found right away instead
* of waiting for requests to an unanswered IP to time out, which can take minutes.
* - Every minute while the live connection is down (or the app is offline), and
* whenever a request fails with a network error: look for the server at a new
* address on the same network.
* A server that answers at its saved address costs one small request; a found
* new address shows a short notice and reloads — the offline queue lives in
* IndexedDB and syncs after the reload.
*/
export default function ServerRediscovery() {
const status = useConnectionStore(s => s.status)
const [movedTo, setMovedTo] = useState(null)
const enabled = canRediscover()
@@ -33,18 +37,24 @@ export default function ServerRediscovery() {
setTimeout(() => window.location.replace(target), RELOAD_DELAY_MS)
}
upgradeToTls().then(url => {
if (cancelled) return
if (url) window.location.reload()
else attempt()
})
const watchdog = setInterval(() => {
const { status, sseAlive } = useConnectionStore.getState()
if (status !== 'online' || !sseAlive) attempt()
}, WATCHDOG_MS)
window.addEventListener('backend-offline', attempt)
let timer = null
if (status === 'offline') {
attempt()
timer = setInterval(attempt, RETRY_WHILE_OFFLINE_MS)
}
return () => {
cancelled = true
clearInterval(watchdog)
window.removeEventListener('backend-offline', attempt)
clearInterval(timer)
}
}, [enabled, status])
}, [enabled])
if (!movedTo) return null
return (
+11 -3
View File
@@ -121,11 +121,19 @@ export function deliveryMode() {
// ── Writing (used by pairing / venue switcher in the native app) ─────────────
/** Add or update a paired venue without switching to it. */
export function saveVenue({ siteId, name, baseUrl }) {
/**
* Add or update a paired venue without switching to it.
* `spki` = the server's TLS public-key pin (native app, plan step 7).
*/
export function saveVenue({ siteId, name, baseUrl, spki }) {
if (!siteId) throw new Error('saveVenue: siteId is required')
const state = readState()
state.venues[siteId] = { siteId, name: name || siteId, baseUrl: normalizeBaseUrl(baseUrl) }
state.venues[siteId] = {
siteId,
name: name || siteId,
baseUrl: normalizeBaseUrl(baseUrl),
...(spki ? { spki } : {}),
}
writeState(state)
return state.venues[siteId]
}
+3 -1
View File
@@ -15,7 +15,9 @@ const native = deliveryMode() === 'native'
if (native) {
import('./native/backButton').then(m => m.installBackButtonHandler())
}
const ready = native && !getActiveVenue() ? Promise.resolve() : db.open()
// Native: hand the paired venues' TLS key pins to the WebView before any request
const trust = native ? import('./native/tls').then(m => m.syncTrustedKeys()) : Promise.resolve()
const ready = trust.then(() => (native && !getActiveVenue() ? null : db.open()))
ready.catch(err => console.error('[IDB] open failed:', err)).finally(() => {
createRoot(document.getElementById('root')).render(
+27
View File
@@ -9,6 +9,7 @@
*/
import { deliveryMode, getActiveVenue, saveVenue } from '../config/server'
import { probeIdentity, scanForVenue } from './rediscovery'
import { syncTrustedKeys, tlsBaseUrl } from './tls'
const MIN_INTERVAL_MS = 60_000
@@ -46,3 +47,29 @@ export function rediscover({ force = false, onProgress } = {}) {
})
return running
}
/**
* Move a venue that is still on plain HTTP (paired before TLS existed, or while
* :8443 wasn't reachable) onto the encrypted endpoint. Runs on every app start
* until it succeeds; afterwards it's a no-op. A key that differs from the one
* stored at pairing is never accepted silently — that venue stays as it is.
* Resolves to the new base URL, or null.
*/
export async function upgradeToTls() {
if (!canRediscover()) return null
const venue = getActiveVenue()
if (!venue.baseUrl.startsWith('http://')) return null
const identity = await probeIdentity(venue.baseUrl)
const spki = identity?.tls?.spki_sha256
if (!spki || identity.site_id !== venue.siteId) return null
if (venue.spki && venue.spki !== spki) return null
await syncTrustedKeys([spki])
const secureUrl = tlsBaseUrl(venue.baseUrl, identity.tls)
const secure = await probeIdentity(secureUrl)
if (secure?.site_id !== venue.siteId) return null
saveVenue({ ...venue, baseUrl: secureUrl, spki })
return secureUrl
}
+29 -4
View File
@@ -10,7 +10,8 @@
*
* Contract: docs/reference/lan-access.md
*/
import { saveVenue } from '../config/server'
import { deliveryMode, saveVenue } from '../config/server'
import { syncTrustedKeys, tlsBaseUrl } from './tls'
// api_version values (from /api/system/identity) this build can talk to.
export const SUPPORTED_API_VERSIONS = [1]
@@ -39,6 +40,7 @@ export function parseServerInput(raw) {
return {
baseUrl: url.origin,
expectedSiteId: url.searchParams.get('pair') || null,
expectedKey: url.searchParams.get('k') || null, // TLS public-key pin from the manager's QR
}
}
@@ -87,7 +89,7 @@ function fallbackSiteId(baseUrl) {
* Does NOT switch to it — the caller decides (switchVenue reloads the app).
*/
export async function pairWith(raw) {
const { baseUrl, expectedSiteId } = parseServerInput(raw)
const { baseUrl, expectedSiteId, expectedKey } = parseServerInput(raw)
const identity = await probeServer(baseUrl)
if (expectedSiteId && identity.site_id && identity.site_id !== expectedSiteId) {
@@ -95,10 +97,33 @@ export async function pairWith(raw) {
'Σε αυτή τη διεύθυνση απαντά διαφορετικό κατάστημα από αυτό του QR. Ζητήστε νέο QR από τον διαχειριστή.'
)
}
const spki = identity.tls?.spki_sha256 || null
if (expectedKey && spki !== expectedKey) {
// The QR carries the real server's key: anything else is a different machine
throw new PairingError(
'Το κλειδί ασφαλείας του server δεν ταιριάζει με το QR. Ζητήστε νέο QR από τον διαχειριστή.'
)
}
return saveVenue({
const venue = {
siteId: identity.site_id || fallbackSiteId(baseUrl),
name: identity.venue_name || new URL(baseUrl).host,
baseUrl,
})
spki,
}
// Native app + server offers TLS: talk to it encrypted from now on. The pin
// comes from the QR (verified above) or, for a typed address, from the server
// itself (trust on first use). If :tls.port isn't reachable yet (proxy not
// updated), stay on plain HTTP — upgradeToTls() retries on every start.
if (spki && identity.site_id && deliveryMode() === 'native') {
const secureUrl = tlsBaseUrl(baseUrl, identity.tls)
await syncTrustedKeys([spki])
try {
const secure = await probeServer(secureUrl)
if (secure.site_id === identity.site_id) venue.baseUrl = secureUrl
} catch {
// keep plain HTTP for now
}
}
return saveVenue(venue)
}
+6 -1
View File
@@ -14,6 +14,10 @@
import { SUPPORTED_API_VERSIONS } from './pairing'
const PROBE_TIMEOUT_MS = 1500
// The first TLS connection to a venue server in a fresh app process takes ~2s
// (WebView certificate check + our pin callback); after that it's milliseconds.
// A shorter timeout would abort a perfectly healthy server.
const TLS_PROBE_TIMEOUT_MS = 5000
const CONCURRENCY = 48
/** Same-/24 candidate origins for a base URL, nearest addresses first. [] if not an IPv4 URL. */
@@ -36,7 +40,8 @@ export function subnetCandidates(baseUrl) {
}
/** Identity of a server, or null (unreachable / not Xenia / unsupported API). */
export async function probeIdentity(origin, { timeoutMs = PROBE_TIMEOUT_MS, signal } = {}) {
export async function probeIdentity(origin, { timeoutMs, signal } = {}) {
timeoutMs ??= origin.startsWith('https:') ? TLS_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const onAbort = () => controller.abort()
+28
View File
@@ -0,0 +1,28 @@
/**
* Encrypted LAN connection for the native app (plan step 7).
*
* Venue servers serve https://<ip>:<tls.port> with a self-signed certificate.
* The native WebView client (MainActivity) accepts it only when the server's
* public-key pin is in the trusted set — which this module keeps equal to the
* pins of all paired venues. Pins are per key, not per address, so IP changes
* and certificate renewals never need anything from the waiter.
*/
import { registerPlugin } from '@capacitor/core'
import { listVenues } from '../config/server'
const XeniaTls = registerPlugin('XeniaTls')
/** Push the pins of all paired venues (+ `extra`, e.g. a venue being paired) to native. */
export async function syncTrustedKeys(extra = []) {
const keys = [...new Set([...listVenues().map(v => v.spki), ...extra].filter(Boolean))]
try {
await XeniaTls.setTrustedKeys({ keys })
} catch (e) {
console.warn('[tls] could not update trusted keys', e)
}
}
/** https://<same host>:<tls port> for a venue's plain-HTTP base URL. */
export function tlsBaseUrl(baseUrl, tls) {
return `https://${new URL(baseUrl).hostname}:${tls.port}`
}