TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first
pair, but the app built a default OkHttpClient with no certificate
handling — self-signed gateway certs were simply rejected.
Build the flow:
- TlsPinning.kt: PinningTrustManager wraps the platform default trust
manager; a rejected cert is accepted only when its SHA-256 fingerprint
matches the user-confirmed pin, anything else fails with
TlsFingerprintRequired (hostname verification still applies).
- SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences +
desktop settings.json), cleared on forget().
- GatewayClient: pinning socket factory on the shared client (all legs
inherit it), new terminal State.TlsConfirmRequired, unwrap the nested
TlsFingerprintRequired in connect loop / watchdog / SSE / poll /
testHello.
- ConnectScreen: confirm dialog showing the fingerprint ("Confirm &
pin" re-runs the connect); IrisApp routes TlsConfirmRequired there;
ChatScreen + desktop tray handle the new state.
- Docs: §9.4 now describes the real flow (incl. SAN requirement), gap
table item 6 → implemented, setup.md limitation note updated.
- Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject,
live pin read, unwrap) + TlsPinningIntegrationTest (real TLS
handshake: unpinned → confirm data, pinned → 200).
Live E2E verified on the phone: first pair against a self-signed
IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works,
auto-reconnect after gateway restart uses the pin.
This commit is contained in:
1 parent
f90e40a3fc
commit
597a28050f
14 files changed
+634
-45
No files matched your search
@@ -19,9 +19,12 @@ import iris.IrisApp
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.DesktopBridge
|
||||
import iris.platform.DesktopSecureStore
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics2D
|
||||
import javax.imageio.ImageIO
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.SystemTray
|
||||
import java.awt.event.WindowEvent
|
||||
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
||||
@@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
|
||||
|
||||
// Window/taskbar icon (src/main/resources/icon.png).
|
||||
private class IrisDesktop
|
||||
|
||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||
|
||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||
@@ -87,15 +88,37 @@ fun main() {
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val state = DesktopBridge.controller?.client?.state?.value
|
||||
val (color, tooltip) = when (state) {
|
||||
null,
|
||||
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
|
||||
is GatewayClient.State.Connecting,
|
||||
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
|
||||
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
|
||||
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||
}
|
||||
val state =
|
||||
DesktopBridge.controller
|
||||
?.client
|
||||
?.state
|
||||
?.value
|
||||
val (color, tooltip) =
|
||||
when (state) {
|
||||
null,
|
||||
is GatewayClient.State.Disconnected,
|
||||
-> {
|
||||
TRAY_OFFLINE to "Iris — offline"
|
||||
}
|
||||
|
||||
is GatewayClient.State.Connecting,
|
||||
is GatewayClient.State.Reconnecting,
|
||||
-> {
|
||||
TRAY_CONNECTING to "Iris — connecting…"
|
||||
}
|
||||
|
||||
is GatewayClient.State.Connected -> {
|
||||
TRAY_CONNECTED to "Iris — connected"
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||
}
|
||||
|
||||
is GatewayClient.State.TlsConfirmRequired -> {
|
||||
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
|
||||
}
|
||||
}
|
||||
trayColor = color
|
||||
trayTooltip = tooltip
|
||||
}
|
||||
@@ -126,15 +149,17 @@ fun main() {
|
||||
state = loadWindowState(),
|
||||
) {
|
||||
composeWindow = window
|
||||
window.addWindowFocusListener(object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
window.addWindowFocusListener(
|
||||
object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
})
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
},
|
||||
)
|
||||
IrisApp(store)
|
||||
}
|
||||
}
|
||||
@@ -187,4 +212,4 @@ private fun saveWindowState(window: ComposeWindow?) {
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@ class AndroidSecureStore(
|
||||
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
|
||||
|
||||
override var pinnedCertFingerprint: String
|
||||
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
|
||||
|
||||
override val deviceId: String
|
||||
get() {
|
||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||
@@ -201,6 +205,7 @@ class AndroidSecureStore(
|
||||
.remove(KEY_NTFY_TOPIC)
|
||||
.remove(KEY_NTFY_SERVER)
|
||||
.remove(KEY_PUSH_BACKEND)
|
||||
.remove(KEY_PINNED_CERT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -216,6 +221,7 @@ class AndroidSecureStore(
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||
const val KEY_PUSH_BACKEND = "push_backend"
|
||||
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
|
||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||
|
||||
@@ -121,6 +121,21 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
|
||||
// TLS: the gateway's certificate is untrusted and not
|
||||
// the pinned one (docs/09 §9.4) — ask the user to
|
||||
// confirm its fingerprint (SSH host-key style).
|
||||
is GatewayClient.State.TlsConfirmRequired -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = pairUrl,
|
||||
prefillToken = pairToken,
|
||||
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
|
||||
tlsFingerprint = s.fingerprint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
|
||||
@@ -17,6 +17,11 @@ interface SecureStore {
|
||||
* gateway can revoke it per device. Empty until the first hello.ack. */
|
||||
var deviceToken: String
|
||||
|
||||
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
|
||||
* certificate, confirmed by the user on first pair (docs/09 §9.4).
|
||||
* Empty = nothing pinned (CA-signed certs need no pin). */
|
||||
var pinnedCertFingerprint: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
val deviceId: String
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ class GatewayClient(
|
||||
data class AuthFailed(
|
||||
val message: String,
|
||||
) : State
|
||||
|
||||
/** The gateway's TLS certificate is untrusted and doesn't match the
|
||||
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
|
||||
* stops and the UI asks the user to confirm the fingerprint. */
|
||||
data class TlsConfirmRequired(
|
||||
val fingerprint: String,
|
||||
) : State
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||
@@ -79,10 +86,18 @@ class GatewayClient(
|
||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||
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 =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
||||
.build()
|
||||
|
||||
private var connectJob: Job? = null
|
||||
@@ -210,6 +225,7 @@ class GatewayClient(
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
false
|
||||
}
|
||||
if (!healthOk) {
|
||||
@@ -247,6 +263,10 @@ class GatewayClient(
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) {
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
false
|
||||
}
|
||||
probeFailures = if (ok) 0 else probeFailures + 1
|
||||
@@ -267,8 +287,9 @@ class GatewayClient(
|
||||
}
|
||||
receiveJob.join()
|
||||
}
|
||||
// Terminal auth failure: don't redial with the same bad token.
|
||||
if (_state.value is State.AuthFailed) return
|
||||
// Terminal failures: don't redial with the same bad token / the
|
||||
// same untrusted certificate (the UI asks the user to act).
|
||||
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +337,7 @@ class GatewayClient(
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
markStreamLost()
|
||||
IrisLog.w("http poll failed: ${e.message}")
|
||||
delay(backoff)
|
||||
@@ -342,6 +364,7 @@ class GatewayClient(
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
sseFailures++
|
||||
markStreamLost()
|
||||
if (sseFailures >= 2) {
|
||||
@@ -422,6 +445,17 @@ class GatewayClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal TLS failure: the presented certificate is untrusted and not
|
||||
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
|
||||
* loop and let the UI ask the user to confirm the fingerprint. True when
|
||||
* the state was set. */
|
||||
private fun failTls(e: Exception): Boolean {
|
||||
val tls = tlsFingerprintRequired(e) ?: return false
|
||||
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
|
||||
_state.value = State.TlsConfirmRequired(tls.fingerprint)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||
@@ -574,13 +608,15 @@ class GatewayClient(
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IllegalStateException("connection failed: ${e.message}"))
|
||||
// Unwrap the pinning manager's signal so the Connect
|
||||
// screen can offer the fingerprint confirm dialog.
|
||||
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
|
||||
} finally {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
Result.failure(tlsFingerprintRequired(e) ?: e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** TLS fingerprint confirm (docs/09 §9.4): pin the gateway's presented
|
||||
* certificate so its self-signed cert is accepted from now on. The
|
||||
* caller re-runs [connect] (or the connect loop picks the pin up on its
|
||||
* next attempt — the trust manager reads the store live). */
|
||||
fun confirmTlsFingerprint(fingerprint: String) {
|
||||
store.pinnedCertFingerprint = fingerprint
|
||||
}
|
||||
|
||||
fun forget() {
|
||||
store.clear()
|
||||
// A different gateway means a different chat universe — wipe the cache.
|
||||
|
||||
@@ -2100,6 +2100,7 @@ private fun statusLabel(state: GatewayClient.State): String =
|
||||
GatewayClient.State.Reconnecting -> "reconnecting…"
|
||||
is GatewayClient.State.Connected -> "connected"
|
||||
is GatewayClient.State.AuthFailed -> "auth failed"
|
||||
is GatewayClient.State.TlsConfirmRequired -> "cert confirm needed"
|
||||
}
|
||||
|
||||
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
|
||||
@@ -2385,6 +2386,7 @@ private fun statusToastText(state: GatewayClient.State): String =
|
||||
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
||||
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
||||
is GatewayClient.State.AuthFailed -> "Unpaired from Hermes"
|
||||
is GatewayClient.State.TlsConfirmRequired -> "Gateway certificate needs confirmation"
|
||||
}
|
||||
|
||||
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
|
||||
@@ -2405,6 +2407,7 @@ private fun StatusBubble(
|
||||
|
||||
GatewayClient.State.Disconnected,
|
||||
is GatewayClient.State.AuthFailed,
|
||||
is GatewayClient.State.TlsConfirmRequired,
|
||||
-> IrisColors.statusRed to false
|
||||
}
|
||||
val alpha = remember { Animatable(1f) }
|
||||
|
||||
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -24,9 +26,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.net.TlsFingerprintRequired
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
@@ -44,6 +48,9 @@ fun ConnectScreen(
|
||||
prefillUrl: String = "",
|
||||
prefillToken: String = "",
|
||||
initialError: String? = null,
|
||||
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
|
||||
* §9.4) — shown as a confirm dialog on entry. */
|
||||
tlsFingerprint: String? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
||||
@@ -52,6 +59,31 @@ fun ConnectScreen(
|
||||
var token by remember { mutableStateOf(prefillToken) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(initialError) }
|
||||
// Fingerprint the user still has to confirm (self-signed gateway cert,
|
||||
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
|
||||
// connect attempt fails with an untrusted certificate.
|
||||
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
|
||||
|
||||
// "Test & Connect" (also the dialog's confirm action): real hello test,
|
||||
// then save + (re)connect. An untrusted gateway certificate surfaces as
|
||||
// the fingerprint confirm dialog instead of a plain error.
|
||||
fun doConnect() {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
val ex = result.exceptionOrNull()
|
||||
if (ex is TlsFingerprintRequired) {
|
||||
pendingFingerprint = ex.fingerprint
|
||||
} else {
|
||||
error = ex?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
@@ -116,18 +148,7 @@ fun ConnectScreen(
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { doConnect() },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
@@ -152,4 +173,43 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
|
||||
// this device doesn't trust (self-signed). The user verifies the
|
||||
// fingerprint on the gateway host, then confirms — the pin is stored in
|
||||
// secure storage and the connect is retried, like an SSH host key.
|
||||
if (pendingFingerprint != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingFingerprint = null },
|
||||
title = { Text("Confirm gateway certificate") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"The gateway presents a certificate this device doesn't trust. " +
|
||||
"Verify the fingerprint on the gateway host, then confirm to pin it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
pendingFingerprint!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val fp = pendingFingerprint!!
|
||||
pendingFingerprint = null
|
||||
controller.confirmTlsFingerprint(fp)
|
||||
doConnect()
|
||||
},
|
||||
) { Text("Confirm & pin") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 runtimeFooterEnabled: Boolean = false,
|
||||
val runtimeFooterFields: String = "",
|
||||
val pinnedCertFingerprint: String = "",
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -284,6 +285,13 @@ class DesktopSecureStore : SecureStore {
|
||||
save(d.copy(runtimeFooterFields = value))
|
||||
}
|
||||
|
||||
override var pinnedCertFingerprint: String
|
||||
get() = load().pinnedCertFingerprint
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(pinnedCertFingerprint = value.trim()))
|
||||
}
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
@@ -311,6 +319,7 @@ class DesktopSecureStore : SecureStore {
|
||||
ntfyTopic = "",
|
||||
ntfyServer = "",
|
||||
pushBackend = "",
|
||||
pinnedCertFingerprint = "",
|
||||
),
|
||||
)
|
||||
secret.clear()
|
||||
|
||||
Reference in new issue
Block a user