From 9ff5951cb264958a015ab9bd0cd65c2f9aec8986 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 16:31:34 +0200 Subject: [PATCH] feat(android): saved-hosts list + unify trust key on address:port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A managed list of known/paired hosts on the connect screen — one-tap reconnect + forget — and a fix for the discovered-vs-manual trust-key split. - kit/security: KnownHostStore (replaces the fp-only PinStore) stores KnownHost{address, port, name, fpHex, paired} keyed by address:port, persisted as JSON in SharedPreferences. So a discovered and a manually-typed connection to the same host now share ONE trust record (the old PinStore keyed discovered hosts by the mDNS instance id, manual by host:port — pairing via one path wasn't seen by the other). - MainActivity: connect() looks up trust by (address, port); on a successful TOFU or PIN pairing the host is saved (paired flag set for the PIN path). A "Saved hosts" section lists them (name, address:port · paired/trusted, fp) with tap-to-reconnect (silent, pinned) and a Forget button. Verified live (emulator -> home-worker-2): pair -> host appears under "Saved hosts" as paired; tap -> silent reconnect (new host session, no dialog); Forget -> removed. Trust now shared across the discovered + manual paths by construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/io/unom/punktfunk/MainActivity.kt | 116 +++++++++++++----- .../punktfunk/kit/security/KnownHostStore.kt | 67 ++++++++++ .../unom/punktfunk/kit/security/PinStore.kt | 27 ---- 3 files changed, 155 insertions(+), 55 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt delete mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index a70b1d9..980b697 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -62,7 +62,8 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost import io.unom.punktfunk.kit.discovery.HostDiscovery import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.IdentityStore -import io.unom.punktfunk.kit.security.PinStore +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.kit.security.obtainIdentity import kotlin.math.abs import kotlinx.coroutines.Dispatchers @@ -144,15 +145,15 @@ private sealed interface Screen { } /** - * A trust decision awaiting the user before a connect proceeds. [hostId] is the PinStore key. - * Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED pair=optional; - * a pair=required host or a manually-typed/unknown-policy host goes straight to PIN pairing - * ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust shortcut. + * A trust decision awaiting the user before a connect proceeds. [name] is the label to save the + * host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED + * pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN + * pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust. */ private data class PendingTrust( val host: String, val port: Int, - val hostId: String, + val name: String, val advertisedFp: String?, val kind: Kind, ) { @@ -206,7 +207,8 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe } val identityStore = remember { IdentityStore(context) } - val pinStore = remember { PinStore(context) } + val knownHostStore = remember { KnownHostStore(context) } + var savedHosts by remember { mutableStateOf(knownHostStore.all()) } // Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and // refuses to connect — never silently shadow-minting a new identity (which would force re-pair). var identity by remember { mutableStateOf(null) } @@ -218,11 +220,10 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing). var pendingTrust by remember { mutableStateOf(null) } - fun hostIdFor(h2: String, p2: Int, dh: DiscoveredHost?) = dh?.key ?: "$h2:$p2" - // Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null), - // persist the fingerprint the host presented so the next connect goes straight through. - fun doConnect(targetHost: String, targetPort: Int, hostId: String, pinHex: String?) { + // pin the fingerprint the host presented (as an unpaired known host) so the next connect goes + // straight through and it appears in the saved-hosts list. + fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { val id = identity if (id == null) { status = "Identity not ready yet — try again in a moment" @@ -241,9 +242,11 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe } connecting = false if (handle != 0L) { - if (pinHex == null) { // TOFU: pin what we observed + if (pinHex == null) { // TOFU: pin what we observed (unpaired) val fp = NativeBridge.nativeHostFingerprint(handle) - if (fp.isNotEmpty()) pinStore.pin(hostId, fp) + if (fp.isNotEmpty()) { + knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false)) + } } onConnected(handle) } else { @@ -253,28 +256,28 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe } } - // Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust-on- - // first-use is permitted ONLY when the host advertised pair=optional (dh.pairingRequired == - // false); a pair=required host, or a manually-typed/unknown-policy host (dh == null), must pair - // by PIN — we never trust an unverified cert on faith. + // Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is + // keyed by address:port, so a discovered and a manually-typed connection to the same host share + // one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a + // pair=required host, or a manual/unknown-policy host, must pair by PIN. fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) { - val hostId = hostIdFor(targetHost, targetPort, dh) - val stored = pinStore.get(hostId) + val known = knownHostStore.get(targetHost, targetPort) val adv = dh?.fingerprint?.lowercase() + val name = dh?.name ?: targetHost when { // Known host whose advertised fp still matches the pin → silent pinned reconnect. - stored != null && (adv == null || adv == stored) -> - doConnect(targetHost, targetPort, hostId, stored) + known != null && (adv == null || adv == known.fpHex) -> + doConnect(targetHost, targetPort, known.name, known.fpHex) // Known host whose fp changed → force re-pairing (no silent re-trust shortcut). - stored != null -> pendingTrust = - PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.FP_CHANGED) + known != null -> pendingTrust = + PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED) // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it, // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. dh?.pairingRequired == false -> pendingTrust = - PendingTrust(targetHost, targetPort, hostId, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) + PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) // pair=required, or a manual/unknown-policy host → PIN pairing is mandatory. else -> pendingTrust = - PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.PAIR) + PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR) } } @@ -287,6 +290,31 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe Text("Android client", style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(24.dp)) + if (savedHosts.isNotEmpty()) { + Text("Saved hosts", style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) { + items(savedHosts, key = { "${it.address}:${it.port}" }) { kh -> + SavedHostRow( + kh, + enabled = !connecting, + onConnect = { + host = kh.address + port = kh.port.toString() + connect(kh.address, kh.port) + }, + onForget = { + knownHostStore.remove(kh.address, kh.port) + savedHosts = knownHostStore.all() + }, + ) + } + } + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + } + if (discovered.isNotEmpty()) { Text("Discovered hosts", style = MaterialTheme.typography.labelLarge) Spacer(Modifier.height(8.dp)) @@ -348,7 +376,7 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe } }, confirmButton = { - TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.hostId, null) }) { + TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) { Text("Trust (TOFU)") } }, @@ -421,9 +449,13 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe } pairing = false if (fp.isNotEmpty()) { - pinStore.pin(pt.hostId, fp) // verified host fp; paired + // Verified host fp — save as a paired known host. + knownHostStore.save( + KnownHost(pt.host, pt.port, pt.name, fp, paired = true), + ) + savedHosts = knownHostStore.all() pendingTrust = null - doConnect(pt.host, pt.port, pt.hostId, fp) + doConnect(pt.host, pt.port, pt.name, fp) } else { err = "Pairing failed — wrong PIN, or the host isn't armed." } @@ -460,6 +492,34 @@ private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () -> } } +@Composable +private fun SavedHostRow( + host: KnownHost, + enabled: Boolean, + onConnect: () -> Unit, + onForget: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .clickable(enabled = enabled, onClick = onConnect) + .padding(vertical = 12.dp), + ) { + Text(host.name, style = MaterialTheme.typography.bodyLarge) + val trust = if (host.paired) "paired" else "trusted (TOFU)" + Text("${host.address}:${host.port} · $trust", style = MaterialTheme.typography.bodySmall) + Text("fp ${host.fpHex.take(16)}…", style = MaterialTheme.typography.labelSmall) + } + TextButton(enabled = enabled, onClick = onForget) { Text("Forget") } + } + } +} + @Composable private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) { val context = LocalContext.current diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt new file mode 100644 index 0000000..80c4700 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt @@ -0,0 +1,67 @@ +package io.unom.punktfunk.kit.security + +import android.content.Context +import org.json.JSONObject + +/** + * A host the user has trusted (pinned). [fpHex] is the pinned host-cert SHA-256 (64-hex); [paired] + * is true when trust was established via the SPAKE2 PIN ceremony (vs trust-on-first-use). + */ +data class KnownHost( + val address: String, + val port: Int, + val name: String, + val fpHex: String, + val paired: Boolean, +) + +/** + * Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by + * `address:port`. Replaces the old fp-only PinStore so a discovered and a manually-typed connection + * to the same host share one trust record (and so saved hosts can be listed + reconnected). Plain + * `SharedPreferences` in app-private storage: pinned fingerprints are public host identities, not + * secrets; the property we need is integrity, which app sandboxing provides. + */ +class KnownHostStore(context: Context) { + private val prefs = + context.applicationContext.getSharedPreferences("punktfunk_hosts", Context.MODE_PRIVATE) + + // The pref key is just a unique id; address/port are also stored in the value so an IPv6 + // address (which contains colons) round-trips without parsing the key. + private fun key(address: String, port: Int) = "$address:$port" + + /** The trusted record for [address]:[port], or `null` if this host has never been trusted. */ + fun get(address: String, port: Int): KnownHost? = + prefs.getString(key(address, port), null)?.let(::parse) + + /** Pin (or update) a trusted host — upsert by `address:port`. */ + fun save(host: KnownHost) { + val json = JSONObject() + .put("addr", host.address) + .put("port", host.port) + .put("name", host.name) + .put("fp", host.fpHex.lowercase()) + .put("paired", host.paired) + prefs.edit().putString(key(host.address, host.port), json.toString()).apply() + } + + /** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */ + fun remove(address: String, port: Int) { + prefs.edit().remove(key(address, port)).apply() + } + + /** All trusted hosts, name-sorted — backs the saved-hosts list. */ + fun all(): List = + prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } + + private fun parse(s: String): KnownHost? = runCatching { + val j = JSONObject(s) + KnownHost( + address = j.getString("addr"), + port = j.getInt("port"), + name = j.getString("name"), + fpHex = j.getString("fp"), + paired = j.optBoolean("paired", false), + ) + }.getOrNull() +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt deleted file mode 100644 index d7da829..0000000 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.unom.punktfunk.kit.security - -import android.content.Context - -/** - * Persists the trusted host fingerprint per host id (TOFU pinning / completed pairing). Keyed by the - * mDNS instance id (`DiscoveredHost.key`) or `"host:port"` for a manually-typed host. Values are - * lowercase 64-hex SHA-256. Plain `SharedPreferences` in app-private storage — pins are not secrets - * (they're public host fingerprints); the security property is integrity, which app sandboxing gives. - */ -class PinStore(context: Context) { - private val prefs = - context.applicationContext.getSharedPreferences("punktfunk_pins", Context.MODE_PRIVATE) - - /** The pinned fingerprint for [hostId], or `null` if this host has never been trusted. */ - fun get(hostId: String): String? = prefs.getString(hostId, null) - - /** Pin (or re-pin) [hostId] to [fpHex]. Normalizes to lowercase. */ - fun pin(hostId: String, fpHex: String) { - prefs.edit().putString(hostId, fpHex.lowercase()).apply() - } - - /** Forget [hostId]'s pin (so the next connect re-TOFUs / re-pairs). */ - fun remove(hostId: String) { - prefs.edit().remove(hostId).apply() - } -}