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
+624 -35

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,14 +88,36 @@ 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
?.client
?.state
?.value
val (color, tooltip) =
when (state) {
null, null,
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline" is GatewayClient.State.Disconnected,
-> {
TRAY_OFFLINE to "Iris — offline"
}
is GatewayClient.State.Connecting, is GatewayClient.State.Connecting,
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…" is GatewayClient.State.Reconnecting,
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected" -> {
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed" 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,7 +149,8 @@ fun main() {
state = loadWindowState(), state = loadWindowState(),
) { ) {
composeWindow = window composeWindow = window
window.addWindowFocusListener(object : WindowFocusListener { window.addWindowFocusListener(
object : WindowFocusListener {
override fun windowGainedFocus(e: WindowEvent) { override fun windowGainedFocus(e: WindowEvent) {
DesktopBridge.foreground = true DesktopBridge.foreground = true
} }
@@ -134,7 +158,8 @@ fun main() {
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()
+12 -3
View File
@@ -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
View File
@@ -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