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
+633
-44
No files matched your search
@@ -19,9 +19,12 @@ import iris.IrisApp
|
|||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.DesktopBridge
|
import iris.platform.DesktopBridge
|
||||||
import iris.platform.DesktopSecureStore
|
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.Color
|
||||||
import java.awt.Graphics2D
|
import java.awt.Graphics2D
|
||||||
import javax.imageio.ImageIO
|
|
||||||
import java.awt.RenderingHints
|
import java.awt.RenderingHints
|
||||||
import java.awt.SystemTray
|
import java.awt.SystemTray
|
||||||
import java.awt.event.WindowEvent
|
import java.awt.event.WindowEvent
|
||||||
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
|
|||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import javax.imageio.ImageIO
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
* 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).
|
// Window/taskbar icon (src/main/resources/icon.png).
|
||||||
private class IrisDesktop
|
private class IrisDesktop
|
||||||
|
|
||||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||||
|
|
||||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||||
@@ -87,15 +88,37 @@ fun main() {
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(2_000)
|
delay(2_000)
|
||||||
val state = DesktopBridge.controller?.client?.state?.value
|
val state =
|
||||||
val (color, tooltip) = when (state) {
|
DesktopBridge.controller
|
||||||
null,
|
?.client
|
||||||
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
|
?.state
|
||||||
is GatewayClient.State.Connecting,
|
?.value
|
||||||
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
|
val (color, tooltip) =
|
||||||
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
|
when (state) {
|
||||||
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
|
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
|
trayColor = color
|
||||||
trayTooltip = tooltip
|
trayTooltip = tooltip
|
||||||
}
|
}
|
||||||
@@ -126,15 +149,17 @@ fun main() {
|
|||||||
state = loadWindowState(),
|
state = loadWindowState(),
|
||||||
) {
|
) {
|
||||||
composeWindow = window
|
composeWindow = window
|
||||||
window.addWindowFocusListener(object : WindowFocusListener {
|
window.addWindowFocusListener(
|
||||||
override fun windowGainedFocus(e: WindowEvent) {
|
object : WindowFocusListener {
|
||||||
DesktopBridge.foreground = true
|
override fun windowGainedFocus(e: WindowEvent) {
|
||||||
}
|
DesktopBridge.foreground = true
|
||||||
|
}
|
||||||
|
|
||||||
override fun windowLostFocus(e: WindowEvent) {
|
override fun windowLostFocus(e: WindowEvent) {
|
||||||
DesktopBridge.foreground = false
|
DesktopBridge.foreground = false
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
IrisApp(store)
|
IrisApp(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
||||||
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
|
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
|
override val deviceId: String
|
||||||
get() {
|
get() {
|
||||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||||
@@ -201,6 +205,7 @@ class AndroidSecureStore(
|
|||||||
.remove(KEY_NTFY_TOPIC)
|
.remove(KEY_NTFY_TOPIC)
|
||||||
.remove(KEY_NTFY_SERVER)
|
.remove(KEY_NTFY_SERVER)
|
||||||
.remove(KEY_PUSH_BACKEND)
|
.remove(KEY_PUSH_BACKEND)
|
||||||
|
.remove(KEY_PINNED_CERT)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +221,7 @@ class AndroidSecureStore(
|
|||||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||||
const val KEY_PUSH_BACKEND = "push_backend"
|
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_THREADS_ENABLED = "threads_enabled"
|
||||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||||
|
|||||||
@@ -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
|
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||||
// status bubble + connection banner show the link state
|
// status bubble + connection banner show the link state
|
||||||
// without blocking the view (M7's full-screen spinner is gone).
|
// without blocking the view (M7's full-screen spinner is gone).
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ interface SecureStore {
|
|||||||
* gateway can revoke it per device. Empty until the first hello.ack. */
|
* gateway can revoke it per device. Empty until the first hello.ack. */
|
||||||
var deviceToken: String
|
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). */
|
/** Stable app-generated device id (persisted). */
|
||||||
val deviceId: String
|
val deviceId: String
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class GatewayClient(
|
|||||||
data class AuthFailed(
|
data class AuthFailed(
|
||||||
val message: String,
|
val message: String,
|
||||||
) : State
|
) : 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>(State.Disconnected)
|
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||||
@@ -79,10 +86,18 @@ class GatewayClient(
|
|||||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
val events: SharedFlow<Frame> = _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 =
|
private val client: OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
.pingInterval(20, TimeUnit.SECONDS)
|
.pingInterval(20, TimeUnit.SECONDS)
|
||||||
|
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
@@ -210,6 +225,7 @@ class GatewayClient(
|
|||||||
try {
|
try {
|
||||||
gw.health()
|
gw.health()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (!healthOk) {
|
if (!healthOk) {
|
||||||
@@ -247,6 +263,10 @@ class GatewayClient(
|
|||||||
try {
|
try {
|
||||||
gw.health()
|
gw.health()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) {
|
||||||
|
receiveJob.cancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
probeFailures = if (ok) 0 else probeFailures + 1
|
probeFailures = if (ok) 0 else probeFailures + 1
|
||||||
@@ -267,8 +287,9 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
receiveJob.join()
|
receiveJob.join()
|
||||||
}
|
}
|
||||||
// Terminal auth failure: don't redial with the same bad token.
|
// Terminal failures: don't redial with the same bad token / the
|
||||||
if (_state.value is State.AuthFailed) return
|
// 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)")
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
markStreamLost()
|
markStreamLost()
|
||||||
IrisLog.w("http poll failed: ${e.message}")
|
IrisLog.w("http poll failed: ${e.message}")
|
||||||
delay(backoff)
|
delay(backoff)
|
||||||
@@ -342,6 +364,7 @@ class GatewayClient(
|
|||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
sseFailures++
|
sseFailures++
|
||||||
markStreamLost()
|
markStreamLost()
|
||||||
if (sseFailures >= 2) {
|
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 ──────────────────────────────────────────────────────────
|
// ── Outbound ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||||
@@ -574,13 +608,15 @@ class GatewayClient(
|
|||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} 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 {
|
} finally {
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(tlsFingerprintRequired(e) ?: e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) {
|
||||||
|
default.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
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<TrustManager>(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
|
||||||
|
}
|
||||||
@@ -1307,6 +1307,14 @@ class IrisController(
|
|||||||
return Result.success(Unit)
|
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() {
|
fun forget() {
|
||||||
store.clear()
|
store.clear()
|
||||||
// A different gateway means a different chat universe — wipe the cache.
|
// A different gateway means a different chat universe — wipe the cache.
|
||||||
|
|||||||
@@ -2100,6 +2100,7 @@ private fun statusLabel(state: GatewayClient.State): String =
|
|||||||
GatewayClient.State.Reconnecting -> "reconnecting…"
|
GatewayClient.State.Reconnecting -> "reconnecting…"
|
||||||
is GatewayClient.State.Connected -> "connected"
|
is GatewayClient.State.Connected -> "connected"
|
||||||
is GatewayClient.State.AuthFailed -> "auth failed"
|
is GatewayClient.State.AuthFailed -> "auth failed"
|
||||||
|
is GatewayClient.State.TlsConfirmRequired -> "cert confirm needed"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
|
/** 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.Reconnecting -> "Re-Connecting to Hermes"
|
||||||
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
||||||
is GatewayClient.State.AuthFailed -> "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,
|
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
|
||||||
@@ -2405,6 +2407,7 @@ private fun StatusBubble(
|
|||||||
|
|
||||||
GatewayClient.State.Disconnected,
|
GatewayClient.State.Disconnected,
|
||||||
is GatewayClient.State.AuthFailed,
|
is GatewayClient.State.AuthFailed,
|
||||||
|
is GatewayClient.State.TlsConfirmRequired,
|
||||||
-> IrisColors.statusRed to false
|
-> IrisColors.statusRed to false
|
||||||
}
|
}
|
||||||
val alpha = remember { Animatable(1f) }
|
val alpha = remember { Animatable(1f) }
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -24,9 +26,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import iris.net.TlsFingerprintRequired
|
||||||
import iris.platform.QrScanButton
|
import iris.platform.QrScanButton
|
||||||
import iris.platform.isDesktop
|
import iris.platform.isDesktop
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
@@ -44,6 +48,9 @@ fun ConnectScreen(
|
|||||||
prefillUrl: String = "",
|
prefillUrl: String = "",
|
||||||
prefillToken: String = "",
|
prefillToken: String = "",
|
||||||
initialError: String? = null,
|
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()
|
val scope = rememberCoroutineScope()
|
||||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
// 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 token by remember { mutableStateOf(prefillToken) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf(initialError) }
|
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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -116,18 +148,7 @@ fun ConnectScreen(
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { doConnect() },
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
@@ -152,4 +173,43 @@ fun ConnectScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Exception> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TlsFingerprintRequired> {
|
||||||
|
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<TlsFingerprintRequired> {
|
||||||
|
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<TlsFingerprintRequired> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val fontSizeScale: Float = 1.0f,
|
val fontSizeScale: Float = 1.0f,
|
||||||
val runtimeFooterEnabled: Boolean = false,
|
val runtimeFooterEnabled: Boolean = false,
|
||||||
val runtimeFooterFields: String = "",
|
val runtimeFooterFields: String = "",
|
||||||
|
val pinnedCertFingerprint: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -284,6 +285,13 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(runtimeFooterFields = value))
|
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(
|
override fun savePairing(
|
||||||
url: String,
|
url: String,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -311,6 +319,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
ntfyTopic = "",
|
ntfyTopic = "",
|
||||||
ntfyServer = "",
|
ntfyServer = "",
|
||||||
pushBackend = "",
|
pushBackend = "",
|
||||||
|
pinnedCertFingerprint = "",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
secret.clear()
|
secret.clear()
|
||||||
|
|||||||
@@ -90,8 +90,17 @@ security principal (the token is).
|
|||||||
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
||||||
network.
|
network.
|
||||||
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
|
- **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
|
(self-signed or CA-signed). CA-signed certs work out of the box.
|
||||||
confirms fingerprint on first pair, like a SSH host key).
|
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):
|
- **Remote reachability options** (documented, user's choice):
|
||||||
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
||||||
app connects over the private mesh. No public exposure.
|
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` |
|
| 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` |
|
| 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 |
|
| 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` |
|
| 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`) |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
+6
-3
@@ -145,9 +145,12 @@ does nothing) — use ntfy (the default), or add Firebase later.
|
|||||||
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
|
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
|
||||||
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
||||||
|
|
||||||
> **Honest limitation:** the app has **no certificate pinning** yet, so
|
> **Self-signed certs:** the app has a fingerprint-confirm flow (docs/09
|
||||||
> self-signed certs won't work — remote access requires **CA-signed** WSS for
|
> §9.4): on first pair it shows the gateway cert's SHA-256 fingerprint; once
|
||||||
> now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default.
|
> 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
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user