diff --git a/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/MainActivity.java b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/MainActivity.java index 0bbf535..179c9c4 100644 --- a/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/MainActivity.java +++ b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/MainActivity.java @@ -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://: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(); + } + } + }); + } +} diff --git a/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/TrustStore.java b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/TrustStore.java new file mode 100644 index 0000000..aace64c --- /dev/null +++ b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/TrustStore.java @@ -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 cache; + + private TrustStore() {} + + static synchronized Set 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 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; + } + } +} diff --git a/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/XeniaTlsPlugin.java b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/XeniaTlsPlugin.java new file mode 100644 index 0000000..6301dd0 --- /dev/null +++ b/waiter_pwa/android/app/src/main/java/gr/bonamin/xenia/XeniaTlsPlugin.java @@ -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 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(); + } +} diff --git a/waiter_pwa/src/components/ServerRediscovery.jsx b/waiter_pwa/src/components/ServerRediscovery.jsx index ca175fb..1453201 100644 --- a/waiter_pwa/src/components/ServerRediscovery.jsx +++ b/waiter_pwa/src/components/ServerRediscovery.jsx @@ -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 ( diff --git a/waiter_pwa/src/config/server.js b/waiter_pwa/src/config/server.js index 262b897..d6b0bd9 100644 --- a/waiter_pwa/src/config/server.js +++ b/waiter_pwa/src/config/server.js @@ -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] } diff --git a/waiter_pwa/src/main.jsx b/waiter_pwa/src/main.jsx index 0874a8d..1dfa8ab 100644 --- a/waiter_pwa/src/main.jsx +++ b/waiter_pwa/src/main.jsx @@ -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( diff --git a/waiter_pwa/src/native/autoRediscover.js b/waiter_pwa/src/native/autoRediscover.js index e61e11e..ab23a98 100644 --- a/waiter_pwa/src/native/autoRediscover.js +++ b/waiter_pwa/src/native/autoRediscover.js @@ -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 +} diff --git a/waiter_pwa/src/native/pairing.js b/waiter_pwa/src/native/pairing.js index 83f234b..2c16d14 100644 --- a/waiter_pwa/src/native/pairing.js +++ b/waiter_pwa/src/native/pairing.js @@ -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) } diff --git a/waiter_pwa/src/native/rediscovery.js b/waiter_pwa/src/native/rediscovery.js index e937c0f..528b057 100644 --- a/waiter_pwa/src/native/rediscovery.js +++ b/waiter_pwa/src/native/rediscovery.js @@ -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() diff --git a/waiter_pwa/src/native/tls.js b/waiter_pwa/src/native/tls.js new file mode 100644 index 0000000..05aed2d --- /dev/null +++ b/waiter_pwa/src/native/tls.js @@ -0,0 +1,28 @@ +/** + * Encrypted LAN connection for the native app (plan step 7). + * + * Venue servers serve https://: 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://: for a venue's plain-HTTP base URL. */ +export function tlsBaseUrl(baseUrl, tls) { + return `https://${new URL(baseUrl).hostname}:${tls.port}` +}