TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
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:
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user