TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
CI / Gateway plugin tests (push) Successful in 5m29s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m22s

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.
This commit is contained in:
ARIA committed 2026-08-24 21:30:42 +02:00
1 parent f90e40a3fc
commit 597a28050f
14 files changed
+634 -45

No files matched your search

@@ -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) {
}
}
}