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