From 597a28050f5f5d88fba91b432d08e322b530db36 Mon Sep 17 00:00:00 2001 From: ARIA Date: Mon, 24 Aug 2026 21:30:42 +0200 Subject: [PATCH] TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first pair, but the app built a default OkHttpClient with no certificate handling — self-signed gateway certs were simply rejected. Build the flow: - TlsPinning.kt: PinningTrustManager wraps the platform default trust manager; a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin, anything else fails with TlsFingerprintRequired (hostname verification still applies). - SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences + desktop settings.json), cleared on forget(). - GatewayClient: pinning socket factory on the shared client (all legs inherit it), new terminal State.TlsConfirmRequired, unwrap the nested TlsFingerprintRequired in connect loop / watchdog / SSE / poll / testHello. - ConnectScreen: confirm dialog showing the fingerprint ("Confirm & pin" re-runs the connect); IrisApp routes TlsConfirmRequired there; ChatScreen + desktop tray handle the new state. - Docs: §9.4 now describes the real flow (incl. SAN requirement), gap table item 6 → implemented, setup.md limitation note updated. - Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject, live pin read, unwrap) + TlsPinningIntegrationTest (real TLS handshake: unpinned → confirm data, pinned → 200). Live E2E verified on the phone: first pair against a self-signed IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works, auto-reconnect after gateway restart uses the pin. --- .../src/main/kotlin/iris/desktop/Main.kt | 71 +++++--- .../iris/platform/AndroidSecureStore.kt | 6 + .../src/commonMain/kotlin/iris/IrisApp.kt | 15 ++ .../kotlin/iris/data/SecureStore.kt | 5 + .../kotlin/iris/net/GatewayClient.kt | 44 ++++- .../commonMain/kotlin/iris/net/TlsPinning.kt | 118 +++++++++++++ .../kotlin/iris/state/IrisController.kt | 8 + .../kotlin/iris/ui/screens/ChatScreen.kt | 3 + .../kotlin/iris/ui/screens/ConnectScreen.kt | 84 +++++++-- .../iris/net/TlsPinningIntegrationTest.kt | 163 ++++++++++++++++++ .../kotlin/iris/net/TlsPinningTest.kt | 129 ++++++++++++++ .../iris/platform/DesktopSecureStore.kt | 9 + docs/09-pairing-security.md | 15 +- docs/setup.md | 9 +- 14 files changed, 634 insertions(+), 45 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt create mode 100644 app/shared/src/commonTest/kotlin/iris/net/TlsPinningIntegrationTest.kt create mode 100644 app/shared/src/commonTest/kotlin/iris/net/TlsPinningTest.kt diff --git a/app/desktopApp/src/main/kotlin/iris/desktop/Main.kt b/app/desktopApp/src/main/kotlin/iris/desktop/Main.kt index b4f04b1..e588f06 100644 --- a/app/desktopApp/src/main/kotlin/iris/desktop/Main.kt +++ b/app/desktopApp/src/main/kotlin/iris/desktop/Main.kt @@ -19,9 +19,12 @@ import iris.IrisApp import iris.net.GatewayClient import iris.platform.DesktopBridge import iris.platform.DesktopSecureStore +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.awt.Color import java.awt.Graphics2D -import javax.imageio.ImageIO import java.awt.RenderingHints import java.awt.SystemTray import java.awt.event.WindowEvent @@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener import java.awt.image.BufferedImage import java.io.File import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.delay -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import javax.imageio.ImageIO /** * M6: desktop shell (docs/11 §11.2/§11.3). @@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso // Window/taskbar icon (src/main/resources/icon.png). private class IrisDesktop + private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap() // Tray icon colors (ARGB). .toInt(): the literals exceed the Int range. @@ -87,15 +88,37 @@ fun main() { LaunchedEffect(Unit) { while (true) { delay(2_000) - val state = DesktopBridge.controller?.client?.state?.value - val (color, tooltip) = when (state) { - null, - is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline" - is GatewayClient.State.Connecting, - is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…" - is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected" - is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed" - } + val state = + DesktopBridge.controller + ?.client + ?.state + ?.value + val (color, tooltip) = + when (state) { + null, + is GatewayClient.State.Disconnected, + -> { + TRAY_OFFLINE to "Iris — offline" + } + + is GatewayClient.State.Connecting, + is GatewayClient.State.Reconnecting, + -> { + TRAY_CONNECTING to "Iris — connecting…" + } + + is GatewayClient.State.Connected -> { + TRAY_CONNECTED to "Iris — connected" + } + + is GatewayClient.State.AuthFailed -> { + TRAY_AUTH_FAILED to "Iris — auth failed" + } + + is GatewayClient.State.TlsConfirmRequired -> { + TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation" + } + } trayColor = color trayTooltip = tooltip } @@ -126,15 +149,17 @@ fun main() { state = loadWindowState(), ) { composeWindow = window - window.addWindowFocusListener(object : WindowFocusListener { - override fun windowGainedFocus(e: WindowEvent) { - DesktopBridge.foreground = true - } + window.addWindowFocusListener( + object : WindowFocusListener { + override fun windowGainedFocus(e: WindowEvent) { + DesktopBridge.foreground = true + } - override fun windowLostFocus(e: WindowEvent) { - DesktopBridge.foreground = false - } - }) + override fun windowLostFocus(e: WindowEvent) { + DesktopBridge.foreground = false + } + }, + ) IrisApp(store) } } @@ -187,4 +212,4 @@ private fun saveWindowState(window: ComposeWindow?) { ) } catch (_: Exception) { } -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 216734b..e288fc8 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -80,6 +80,10 @@ class AndroidSecureStore( get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty() set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply() + override var pinnedCertFingerprint: String + get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty() + set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply() + override val deviceId: String get() { var id = prefs.getString(KEY_DEVICE_ID, null) @@ -201,6 +205,7 @@ class AndroidSecureStore( .remove(KEY_NTFY_TOPIC) .remove(KEY_NTFY_SERVER) .remove(KEY_PUSH_BACKEND) + .remove(KEY_PINNED_CERT) .apply() } @@ -216,6 +221,7 @@ class AndroidSecureStore( const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_PUSH_BACKEND = "push_backend" + const val KEY_PINNED_CERT = "pinned_cert_fingerprint" const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_STREAMING_ENABLED = "streaming_enabled" diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt index 71def05..21e68ab 100644 --- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt +++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt @@ -121,6 +121,21 @@ fun IrisApp( } } + // TLS: the gateway's certificate is untrusted and not + // the pinned one (docs/09 §9.4) — ask the user to + // confirm its fingerprint (SSH host-key style). + is GatewayClient.State.TlsConfirmRequired -> { + key(deepLinkPair) { + ConnectScreen( + controller, + prefillUrl = pairUrl, + prefillToken = pairToken, + initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.", + tlsFingerprint = s.fingerprint, + ) + } + } + // Connecting / Reconnecting / Connected all render the chat; the header // status bubble + connection banner show the link state // without blocking the view (M7's full-screen spinner is gone). diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index 07d2cc1..bf11419 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -17,6 +17,11 @@ interface SecureStore { * gateway can revoke it per device. Empty until the first hello.ack. */ var deviceToken: String + /** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS + * certificate, confirmed by the user on first pair (docs/09 §9.4). + * Empty = nothing pinned (CA-signed certs need no pin). */ + var pinnedCertFingerprint: String + /** Stable app-generated device id (persisted). */ val deviceId: String diff --git a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt index 7a2f030..5b9eac6 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt @@ -67,6 +67,13 @@ class GatewayClient( data class AuthFailed( val message: String, ) : State + + /** The gateway's TLS certificate is untrusted and doesn't match the + * pinned fingerprint (docs/09 §9.4). Terminal: the connect loop + * stops and the UI asks the user to confirm the fingerprint. */ + data class TlsConfirmRequired( + val fingerprint: String, + ) : State } private val _state = MutableStateFlow(State.Disconnected) @@ -79,10 +86,18 @@ class GatewayClient( private val _events = MutableSharedFlow(extraBufferCapacity = 128) val events: SharedFlow = _events.asSharedFlow() + // TLS: the pinning trust manager wraps the platform default (CA-signed + // certs behave as before); a self-signed gateway cert is accepted only + // after the user confirms its fingerprint (docs/09 §9.4). The pin is + // read live from the store so a fresh confirm takes effect without + // rebuilding the client. + private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint } + private val client: OkHttpClient = OkHttpClient .Builder() .pingInterval(20, TimeUnit.SECONDS) + .sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm) .build() private var connectJob: Job? = null @@ -210,6 +225,7 @@ class GatewayClient( try { gw.health() } catch (e: Exception) { + if (failTls(e)) return false } if (!healthOk) { @@ -247,6 +263,10 @@ class GatewayClient( try { gw.health() } catch (e: Exception) { + if (failTls(e)) { + receiveJob.cancel() + break + } false } probeFailures = if (ok) 0 else probeFailures + 1 @@ -267,8 +287,9 @@ class GatewayClient( } receiveJob.join() } - // Terminal auth failure: don't redial with the same bad token. - if (_state.value is State.AuthFailed) return + // Terminal failures: don't redial with the same bad token / the + // same untrusted certificate (the UI asks the user to act). + if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return } } @@ -316,6 +337,7 @@ class GatewayClient( _state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") return } catch (e: Exception) { + if (failTls(e)) return markStreamLost() IrisLog.w("http poll failed: ${e.message}") delay(backoff) @@ -342,6 +364,7 @@ class GatewayClient( _state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") return } catch (e: Exception) { + if (failTls(e)) return sseFailures++ markStreamLost() if (sseFailures >= 2) { @@ -422,6 +445,17 @@ class GatewayClient( } } + /** Terminal TLS failure: the presented certificate is untrusted and not + * the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect + * loop and let the UI ask the user to confirm the fingerprint. True when + * the state was set. */ + private fun failTls(e: Exception): Boolean { + val tls = tlsFingerprintRequired(e) ?: return false + IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})") + _state.value = State.TlsConfirmRequired(tls.fingerprint) + return true + } + // ── Outbound ────────────────────────────────────────────────────────── /** Send a text message (fire-and-forget; the server echoes it back). @@ -574,13 +608,15 @@ class GatewayClient( } catch (e: CancellationException) { throw e } catch (e: Exception) { - Result.failure(IllegalStateException("connection failed: ${e.message}")) + // Unwrap the pinning manager's signal so the Connect + // screen can offer the fingerprint confirm dialog. + Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}")) } finally { job.cancel() } } } catch (e: Exception) { - Result.failure(e) + Result.failure(tlsFingerprintRequired(e) ?: e) } } diff --git a/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt b/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt new file mode 100644 index 0000000..5e9cb25 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt @@ -0,0 +1,118 @@ +package iris.net + +import java.security.KeyStore +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * TLS certificate pinning for self-signed gateways (docs/09 §9.4). + * + * A gateway behind `IRIS_HTTP_CERT`/`IRIS_WS_CERT` may present a + * self-signed certificate the platform doesn't trust. Instead of forcing the + * user to install it into the system trust store, the app shows the + * certificate's SHA-256 fingerprint on first pair (SSH host-key style); once + * the user confirms it, the fingerprint is pinned in secure storage and + * [PinningTrustManager] accepts exactly that certificate from then on. + * + * Hostname verification is NOT bypassed: OkHttp runs its own hostname check + * on top of the trust manager, so a pinned cert still has to match the URL's + * host. A *changed* certificate (different fingerprint) fails again with + * [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH + * host key. + */ + +/** + * The gateway presented a certificate the platform doesn't trust and that + * doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the + * user must confirm. Thrown by [PinningTrustManager] during the handshake; + * the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired]. + */ +class TlsFingerprintRequired( + val fingerprint: String, +) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)") + +/** + * SHA-256 of the certificate's DER encoding, colon-separated byte pairs + * (SSH host-key style) — the string the user verifies on the gateway host. + */ +fun certFingerprint(cert: X509Certificate): String = + MessageDigest + .getInstance("SHA-256") + .digest(cert.encoded) + .joinToString(":") { String.format("%02X", it) } + +/** + * Wraps the platform default trust manager: + * - CA-signed certs behave exactly as before (the default manager decides). + * - A cert the default manager REJECTS is accepted only when its fingerprint + * equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh + * pin is picked up without rebuilding the client). + * - Anything else fails with [TlsFingerprintRequired] carrying the + * presented fingerprint, so the UI can offer the confirm dialog. + */ +class PinningTrustManager( + private val pinnedFingerprint: () -> String, +) : X509TrustManager { + private val default: X509TrustManager = defaultTrustManager() + + override fun checkClientTrusted( + chain: Array, + authType: String, + ) { + default.checkClientTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = default.acceptedIssuers + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) { + try { + default.checkServerTrusted(chain, authType) + } catch (e: CertificateException) { + val fp = certFingerprint(chain.first()) + if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return + throw TlsFingerprintRequired(fp) + } + } +} + +/** The platform default server trust manager (the JDK's CA store). */ +fun defaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager) + ?: error("no X509TrustManager in the default trust store") +} + +/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */ +fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory { + val ctx = SSLContext.getInstance("TLS") + ctx.init(null, arrayOf(tm), SecureRandom()) + return ctx.socketFactory +} + +/** + * Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport + * exception: the trust manager throws it during the handshake and the + * JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when + * the failure has nothing to do with an untrusted gateway certificate. + */ +fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? { + var t: Throwable? = e + var depth = 0 + while (t != null && depth < 10) { + if (t is TlsFingerprintRequired) return t + t = t.cause + depth++ + } + return null +} diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index fbf54cd..e83d5a7 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -1307,6 +1307,14 @@ class IrisController( return Result.success(Unit) } + /** TLS fingerprint confirm (docs/09 §9.4): pin the gateway's presented + * certificate so its self-signed cert is accepted from now on. The + * caller re-runs [connect] (or the connect loop picks the pin up on its + * next attempt — the trust manager reads the store live). */ + fun confirmTlsFingerprint(fingerprint: String) { + store.pinnedCertFingerprint = fingerprint + } + fun forget() { store.clear() // A different gateway means a different chat universe — wipe the cache. diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index fb4e690..2a66f80 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -2100,6 +2100,7 @@ private fun statusLabel(state: GatewayClient.State): String = GatewayClient.State.Reconnecting -> "reconnecting…" is GatewayClient.State.Connected -> "connected" is GatewayClient.State.AuthFailed -> "auth failed" + is GatewayClient.State.TlsConfirmRequired -> "cert confirm needed" } /** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */ @@ -2385,6 +2386,7 @@ private fun statusToastText(state: GatewayClient.State): String = GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes" GatewayClient.State.Disconnected -> "Unpaired from Hermes" is GatewayClient.State.AuthFailed -> "Unpaired from Hermes" + is GatewayClient.State.TlsConfirmRequired -> "Gateway certificate needs confirmation" } /** Header status bubble: green = connected, yellow pulsing = (re)connecting, @@ -2405,6 +2407,7 @@ private fun StatusBubble( GatewayClient.State.Disconnected, is GatewayClient.State.AuthFailed, + is GatewayClient.State.TlsConfirmRequired, -> IrisColors.statusRed to false } val alpha = remember { Animatable(1f) } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt index d6938e7..ed4b498 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,9 +26,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import iris.net.TlsFingerprintRequired import iris.platform.QrScanButton import iris.platform.isDesktop import iris.state.IrisController @@ -44,6 +48,9 @@ fun ConnectScreen( prefillUrl: String = "", prefillToken: String = "", initialError: String? = null, + /** Gateway certificate fingerprint awaiting user confirmation (docs/09 + * §9.4) — shown as a confirm dialog on entry. */ + tlsFingerprint: String? = null, ) { val scope = rememberCoroutineScope() // Default is a cleartext (non-TLS) URL because the typical gateway is on @@ -52,6 +59,31 @@ fun ConnectScreen( var token by remember { mutableStateOf(prefillToken) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(initialError) } + // Fingerprint the user still has to confirm (self-signed gateway cert, + // docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a + // connect attempt fails with an untrusted certificate. + var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) } + + // "Test & Connect" (also the dialog's confirm action): real hello test, + // then save + (re)connect. An untrusted gateway certificate surfaces as + // the fingerprint confirm dialog instead of a plain error. + fun doConnect() { + if (busy) return + busy = true + error = null + scope.launch { + val result = controller.connect(url.trim(), token.trim()) + busy = false + if (result.isFailure) { + val ex = result.exceptionOrNull() + if (ex is TlsFingerprintRequired) { + pendingFingerprint = ex.fingerprint + } else { + error = ex?.message ?: "connection failed" + } + } + } + } Column( modifier = @@ -116,18 +148,7 @@ fun ConnectScreen( Spacer(modifier = Modifier.height(24.dp)) Button( - onClick = { - if (busy) return@Button - busy = true - error = null - scope.launch { - val result = controller.connect(url.trim(), token.trim()) - busy = false - if (result.isFailure) { - error = result.exceptionOrNull()?.message ?: "connection failed" - } - } - }, + onClick = { doConnect() }, enabled = !busy, modifier = Modifier.fillMaxWidth(), ) { @@ -152,4 +173,43 @@ fun ConnectScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + // Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate + // this device doesn't trust (self-signed). The user verifies the + // fingerprint on the gateway host, then confirms — the pin is stored in + // secure storage and the connect is retried, like an SSH host key. + if (pendingFingerprint != null) { + AlertDialog( + onDismissRequest = { pendingFingerprint = null }, + title = { Text("Confirm gateway certificate") }, + text = { + Column { + Text( + "The gateway presents a certificate this device doesn't trust. " + + "Verify the fingerprint on the gateway host, then confirm to pin it.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + pendingFingerprint!!, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + }, + confirmButton = { + Button( + onClick = { + val fp = pendingFingerprint!! + pendingFingerprint = null + controller.confirmTlsFingerprint(fp) + doConnect() + }, + ) { Text("Confirm & pin") } + }, + dismissButton = { + TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") } + }, + ) + } } diff --git a/app/shared/src/commonTest/kotlin/iris/net/TlsPinningIntegrationTest.kt b/app/shared/src/commonTest/kotlin/iris/net/TlsPinningIntegrationTest.kt new file mode 100644 index 0000000..35d1624 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/net/TlsPinningIntegrationTest.kt @@ -0,0 +1,163 @@ +package iris.net + +import com.sun.net.httpserver.HttpsConfigurator +import com.sun.net.httpserver.HttpsServer +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayInputStream +import java.net.InetSocketAddress +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +/** + * docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real + * TLS handshake: a local HTTPS server presents the embedded self-signed + * certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like + * [GatewayClient] (pinning socket factory + trust manager). + * + * 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the + * presented fingerprint from the nested exception. + * 2. After "confirming" (setting the pin): the SAME client connects — the + * pin is read live, no client rebuild. + * + * The embedded key is a throwaway test key, not a secret. + */ +class TlsPinningIntegrationTest { + private companion object { + // Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname + // verifier requires a SAN; CN-only certs are rejected even when pinned). + val CERT_PEM = + """ + -----BEGIN CERTIFICATE----- + MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL + BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z + NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG + SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF + /R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C + Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT + qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs + PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a + GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7 + MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd + WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm + EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3 + Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE + dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw + zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0 + Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU= + -----END CERTIFICATE----- + """.trimIndent() + + val KEY_PEM = + """ + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G + uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc + fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H + eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7 + bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC + qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7 + OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5 + PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd + sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r + yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g + WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN + ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE + BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f + utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem + Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK + ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu + OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H + yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w + Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6 + 190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF + JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/ + LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M + JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI + MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z + OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO + sZ1EltfJHT57a0ub22mRtVc= + -----END PRIVATE KEY----- + """.trimIndent() + + // `openssl x509 -noout -fingerprint -sha256` over CERT_PEM. + const val EXPECTED_FINGERPRINT = + "9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44" + + fun pemBody(pem: String): ByteArray { + val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "") + return Base64.getDecoder().decode(base64) + } + } + + @Test + fun selfSignedGatewayRequiresConfirmThenPins() { + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray())) + .let { it as java.security.cert.X509Certificate } + val key = + KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM))) + + // Local HTTPS server presenting the self-signed cert. + val ks = KeyStore.getInstance(KeyStore.getDefaultType()) + ks.load(null, null) + ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert)) + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(ks, CharArray(0)) + val serverCtx = SSLContext.getInstance("TLS") + serverCtx.init(kmf.keyManagers, null, null) + + val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.httpsConfigurator = HttpsConfigurator(serverCtx) + server.createContext("/v1/health") { exchange -> + val body = "ok".toByteArray() + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.start() + + // The client is wired exactly like GatewayClient: pinning socket + // factory + trust manager, pin read live from a mutable holder. + var pin = "" + val tm = PinningTrustManager { pin } + val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build() + val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build() + + try { + // 1. Unpinned: the handshake fails; the unwrap finds the + // presented fingerprint in the nested exception chain. + val e = + assertFailsWith { + client.newCall(request).execute().use { it.body!!.string() } + } + val tls = tlsFingerprintRequired(e) + assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e") + assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint) + + // 2. User "confirms" the fingerprint: the SAME client now + // connects (the pin is read live, no client rebuild). + pin = EXPECTED_FINGERPRINT + client.newCall(request).execute().use { response -> + assertEquals(200, response.code) + assertEquals("ok", response.body!!.string()) + } + } finally { + server.stop(0) + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } + } +} diff --git a/app/shared/src/commonTest/kotlin/iris/net/TlsPinningTest.kt b/app/shared/src/commonTest/kotlin/iris/net/TlsPinningTest.kt new file mode 100644 index 0000000..6513b15 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/net/TlsPinningTest.kt @@ -0,0 +1,129 @@ +package iris.net + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow. + * + * The embedded certificate is a self-signed cert (CN=iris-test-gateway) the + * platform trust store does NOT contain, so it exercises the exact path a + * self-signed gateway hits: default trust manager rejects → the pinning + * manager either offers the fingerprint for confirmation or accepts the + * user-confirmed pin. + */ +class TlsPinningTest { + private companion object { + // Self-signed, 10-year validity — generated with: + // openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway" + val SELF_SIGNED_PEM = + """ + -----BEGIN CERTIFICATE----- + MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL + BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3 + WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC + ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp + 9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V + LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs + BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc + HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66 + UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC + AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY + MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI + hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj + GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/ + Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S + vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA + I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2 + vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs= + -----END CERTIFICATE----- + """.trimIndent() + + // `openssl x509 -noout -fingerprint -sha256` over the same cert. + const val EXPECTED_FINGERPRINT = + "C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA" + + val CERT: X509Certificate by lazy { + CertificateFactory + .getInstance("X.509") + .generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray())) + .let { it as X509Certificate } + } + } + + @Test + fun certFingerprintMatchesOpenSsl() { + assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT)) + } + + @Test + fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() { + val tm = PinningTrustManager { "" } + val e = + assertFailsWith { + tm.checkServerTrusted(arrayOf(CERT), "RSA") + } + assertEquals(EXPECTED_FINGERPRINT, e.fingerprint) + } + + @Test + fun confirmedPinAcceptsTheSelfSignedCert() { + val tm = PinningTrustManager { EXPECTED_FINGERPRINT } + // Must not throw: the user confirmed exactly this certificate. + tm.checkServerTrusted(arrayOf(CERT), "RSA") + } + + @Test + fun wrongPinStillFailsWithThePresentedFingerprint() { + val tm = PinningTrustManager { "DE:AD:BE:EF" } + val e = + assertFailsWith { + tm.checkServerTrusted(arrayOf(CERT), "RSA") + } + assertEquals(EXPECTED_FINGERPRINT, e.fingerprint) + } + + @Test + fun pinIsReadLive() { + // The provider is a lambda: a pin saved AFTER the manager was built + // (the confirm dialog's action) takes effect without rebuilding it. + var pin = "" + val tm = PinningTrustManager { pin } + assertFailsWith { + tm.checkServerTrusted(arrayOf(CERT), "RSA") + } + pin = EXPECTED_FINGERPRINT + tm.checkServerTrusted(arrayOf(CERT), "RSA") + } + + @Test + fun unwrapFindsNestedTlsFingerprintRequired() { + val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT) + // The JSSE/OkHttp layers wrap the trust manager's exception in an + // (SSL) handshake IOException — the unwrap must find it nested. + val wrapped = IOException("PKIX path building failed", inner) + val found = tlsFingerprintRequired(wrapped) + assertNotNull(found) + assertEquals(EXPECTED_FINGERPRINT, found.fingerprint) + // Unrelated failures must not be misread as a confirm request. + assertNull(tlsFingerprintRequired(IOException("remote host closed connection"))) + assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable"))) + } + + @Test + fun pinningSocketFactoryCreatesSockets() { + val tm = PinningTrustManager { "" } + val factory = pinningSslSocketFactory(tm) + val socket = factory.createSocket() + assertTrue(socket.javaClass.name.contains("SSL")) + socket.close() + } +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index 7e71598..e315d2a 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -73,6 +73,7 @@ class DesktopSecureStore : SecureStore { val fontSizeScale: Float = 1.0f, val runtimeFooterEnabled: Boolean = false, val runtimeFooterFields: String = "", + val pinnedCertFingerprint: String = "", ) init { @@ -284,6 +285,13 @@ class DesktopSecureStore : SecureStore { save(d.copy(runtimeFooterFields = value)) } + override var pinnedCertFingerprint: String + get() = load().pinnedCertFingerprint + set(value) { + val d = load() + save(d.copy(pinnedCertFingerprint = value.trim())) + } + override fun savePairing( url: String, token: String, @@ -311,6 +319,7 @@ class DesktopSecureStore : SecureStore { ntfyTopic = "", ntfyServer = "", pushBackend = "", + pinnedCertFingerprint = "", ), ) secret.clear() diff --git a/docs/09-pairing-security.md b/docs/09-pairing-security.md index ee3771c..07a9f17 100644 --- a/docs/09-pairing-security.md +++ b/docs/09-pairing-security.md @@ -90,8 +90,17 @@ security principal (the token is). - **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home network. - **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY` - (self-signed or CA-signed). The app pins/accepts the cert (self-signed → user - confirms fingerprint on first pair, like a SSH host key). + (self-signed or CA-signed). CA-signed certs work out of the box. + For a **self-signed** cert the app shows its SHA-256 fingerprint on first + pair (like a SSH host key); once the user confirms it, the fingerprint is + pinned in secure storage (`SecureStore.pinnedCertFingerprint`) and the + app's `PinningTrustManager` accepts exactly that certificate from then on + (hostname verification still applies). A *changed* certificate fails + again with a fresh confirm request — the user must re-confirm, like a + changed SSH host key. No system trust-store install needed. Note: the cert + must carry a **SAN** for the URL host (OkHttp's hostname verifier rejects + CN-only certs even when pinned) — e.g. `openssl req -x509 ... -addext + "subjectAltName=DNS:myhost,IP:192.168.1.10"`. - **Remote reachability options** (documented, user's choice): - **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP; app connects over the private mesh. No public exposure. @@ -154,7 +163,7 @@ M7 research pass. "verified" = implemented and covered by | 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` | | 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` | | 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned | -| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default | +| 6 | WSS + cert pinning for remote | implemented | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_WS_KEY`, `ws_server.py:122`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `ws://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` | | 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` | | 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) | | 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap | diff --git a/docs/setup.md b/docs/setup.md index 842029e..b8eb95a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -145,9 +145,12 @@ does nothing) — use ntfy (the default), or add Firebase later. - **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in `~/.hermes/.env`) and the server serves `wss://` instead of `ws://`. -> **Honest limitation:** the app has **no certificate pinning** yet, so -> self-signed certs won't work — remote access requires **CA-signed** WSS for -> now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default. +> **Self-signed certs:** the app has a fingerprint-confirm flow (docs/09 +> §9.4): on first pair it shows the gateway cert's SHA-256 fingerprint; once +> you confirm it, the cert is pinned in secure storage (like an SSH host +> key). The cert needs a SAN for the URL host. CA-signed certs work out of +> the box. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the +> default. ## 6. Troubleshooting