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:
@@ -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