feat(android): saved-hosts list + unify trust key on address:port

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:31:34 +02:00
parent 8265742e74
commit 9ff5951cb2
3 changed files with 155 additions and 55 deletions
@@ -62,7 +62,8 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore 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 io.unom.punktfunk.kit.security.obtainIdentity
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.Dispatchers 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. * A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
* Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED pair=optional; * host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
* a pair=required host or a manually-typed/unknown-policy host goes straight to PIN pairing * pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
* ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust shortcut. * pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
*/ */
private data class PendingTrust( private data class PendingTrust(
val host: String, val host: String,
val port: Int, val port: Int,
val hostId: String, val name: String,
val advertisedFp: String?, val advertisedFp: String?,
val kind: Kind, val kind: Kind,
) { ) {
@@ -206,7 +207,8 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe
} }
val identityStore = remember { IdentityStore(context) } 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 // 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). // refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) } var identity by remember { mutableStateOf<ClientIdentity?>(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). // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) } var pendingTrust by remember { mutableStateOf<PendingTrust?>(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), // 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. // pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
fun doConnect(targetHost: String, targetPort: Int, hostId: String, pinHex: String?) { // straight through and it appears in the saved-hosts list.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity val id = identity
if (id == null) { if (id == null) {
status = "Identity not ready yet — try again in a moment" 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 connecting = false
if (handle != 0L) { 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) 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) onConnected(handle)
} else { } 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- // Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
// first-use is permitted ONLY when the host advertised pair=optional (dh.pairingRequired == // keyed by address:port, so a discovered and a manually-typed connection to the same host share
// false); a pair=required host, or a manually-typed/unknown-policy host (dh == null), must pair // one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// by PIN — we never trust an unverified cert on faith. // pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) { fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
val hostId = hostIdFor(targetHost, targetPort, dh) val known = knownHostStore.get(targetHost, targetPort)
val stored = pinStore.get(hostId)
val adv = dh?.fingerprint?.lowercase() val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost
when { when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect. // Known host whose advertised fp still matches the pin → silent pinned reconnect.
stored != null && (adv == null || adv == stored) -> known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, hostId, stored) doConnect(targetHost, targetPort, known.name, known.fpHex)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut). // Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
stored != null -> pendingTrust = known != null -> pendingTrust =
PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.FP_CHANGED) PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it, // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust = 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. // pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
else -> pendingTrust = 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) Text("Android client", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(24.dp)) 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()) { if (discovered.isNotEmpty()) {
Text("Discovered hosts", style = MaterialTheme.typography.labelLarge) Text("Discovered hosts", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -348,7 +376,7 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe
} }
}, },
confirmButton = { 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)") Text("Trust (TOFU)")
} }
}, },
@@ -421,9 +449,13 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe
} }
pairing = false pairing = false
if (fp.isNotEmpty()) { 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 pendingTrust = null
doConnect(pt.host, pt.port, pt.hostId, fp) doConnect(pt.host, pt.port, pt.name, fp)
} else { } else {
err = "Pairing failed — wrong PIN, or the host isn't armed." 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 @Composable
private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) { private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -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<KnownHost> =
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()
}
@@ -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()
}
}