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:
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user