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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user