feat(client/android): hosts get a stable identity, and own their clipboard
The host store was keyed by `"address:port"`, which had two consequences. Editing a host's address had to re-key its record — delete the old key, write the new one — and nothing could hold a durable reference to a host at all, because the key moved whenever the host did. Profile bindings, pinned cards and `punktfunk://` shortcuts all need exactly such a reference, so the store is now keyed by a minted stable id: a lowercase UUID, the same shape Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host reference is one grammar on every platform. The re-key-on-edit dance is gone; an edit is a plain save. `clipboardSync` moves from a global onto the record. It was always a decision about a HOST — whether text on this device may cross to that machine — and one global for the work box and the couch box was the wrong shape; the desktop clients have had it per-host since it shipped. It is edited from the host's Edit sheet, which is also where the profile binding will live. Both, plus the minted ids, ride ONE migration pass. The store is being rewritten anyway and every extra pass is another chance to strand somebody's hosts. It runs once against real user data, so its pure half is tested against a verbatim pre-migration blob — every host survives with its pin, paired flag and MACs, IPv6 addresses included; each lands on its own id; whatever the global clipboard setting said lands on every host, on or off; and a second pass over its own output changes nothing. Re-trusting a host now goes through `KnownHostStore.trust`, which preserves the existing record's identity and everything the user set on it. Three call sites used to construct a fresh `KnownHost` — harmless while the key was the address, but with an id it would have forked the record on every re-pair. While the wiring was open: `StreamScreen` takes an `ActiveSession` — the settings the connect actually resolved, plus that host's clipboard answer — instead of re-reading `SettingsStore` behind its own connect's back. That is also the seam profiles need.
This commit is contained in:
+186
-39
@@ -1,11 +1,19 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import android.content.Context
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
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).
|
||||
*
|
||||
* [id] is the record's **stable identity** — minted once, never changed, and the key this record is
|
||||
* stored under. Everything that needs to point AT a host (a settings-profile binding, a pinned
|
||||
* card, a `punktfunk://` link) points at the id, so renaming a host or moving it to a new address
|
||||
* doesn't strand those references. Mirrors the Apple client's `StoredHost.id` and the Rust
|
||||
* `KnownHost.id`; the shape is a lowercase UUID v4, one grammar on every platform.
|
||||
*/
|
||||
data class KnownHost(
|
||||
val address: String,
|
||||
@@ -18,37 +26,79 @@ data class KnownHost(
|
||||
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||
*/
|
||||
val mac: List<String> = emptyList(),
|
||||
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
|
||||
val id: String = newRecordId(),
|
||||
/**
|
||||
* Sync text copied on this device to this host and back while streaming. **A property of the
|
||||
* host, not of the stream** (design/client-settings-profiles.md §3, tier H): it is a trust
|
||||
* decision about that machine, so it is never in a settings profile and never global — the
|
||||
* work box and the couch box get their own answers. Only effective when the host advertises
|
||||
* the clipboard capability; the protocol is opt-in per session either way.
|
||||
*/
|
||||
val clipboardSync: Boolean = true,
|
||||
/**
|
||||
* The settings profile a plain tap on this host connects with — `null` (or an id whose profile
|
||||
* was deleted) means the global defaults, i.e. today's behaviour. A dangling id is never an
|
||||
* error and never blocks a connect.
|
||||
*/
|
||||
val profileId: String? = null,
|
||||
/**
|
||||
* Profiles pinned as their own cards for this host (design §5.2a). Presentation only: order is
|
||||
* card order, and this is NOT the default binding ([profileId] is). Duplicates and profiles
|
||||
* that no longer exist are dropped when the cards are rendered.
|
||||
*/
|
||||
val pinnedProfileIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* [KnownHost.id]. Plain `SharedPreferences` in app-private storage: pinned fingerprints are public
|
||||
* host identities, not secrets; the property we need is integrity, which app sandboxing provides.
|
||||
*
|
||||
* Records used to be keyed by `"address:port"`, which meant editing a host's address had to
|
||||
* re-key its record (delete + write) or leave a ghost behind, and meant nothing could hold a
|
||||
* durable reference to a host. Keying by the minted stable id retires both. [migrate] moves an
|
||||
* existing store over in one pass — see its doc for what else rides along.
|
||||
*/
|
||||
class KnownHostStore(context: Context) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences("punktfunk_hosts", Context.MODE_PRIVATE)
|
||||
context.applicationContext.getSharedPreferences(PREFS_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"
|
||||
init {
|
||||
migrateIfNeeded(context)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
all().firstOrNull { it.address == address && it.port == port }
|
||||
|
||||
/** Pin (or update) a trusted host — upsert by `address:port`. */
|
||||
/** The trusted record with this stable [id], or `null` — the lookup a binding or link uses. */
|
||||
fun byId(id: String): KnownHost? = prefs.getString(id, null)?.let(::parse)
|
||||
|
||||
/**
|
||||
* Pin (or update) a trusted host — upsert by [KnownHost.id]. An edit that moves the address or
|
||||
* port is a plain save now: the key is the identity, not the address.
|
||||
*/
|
||||
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)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||
prefs.edit().putString(host.id, encode(host)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust (or re-trust) the host at [address]:[port] with the fingerprint it presented.
|
||||
*
|
||||
* When a record already exists there — a re-pair after the host's identity changed, an
|
||||
* approval that upgrades a TOFU record to paired — it keeps its identity and everything the
|
||||
* user set on it: the stable [KnownHost.id] (so profile bindings, pinned cards and any
|
||||
* `punktfunk://` shortcut still point at it), the per-host clipboard decision, the binding,
|
||||
* the pins and the learned MACs. Only the name, pin and paired flag are refreshed. Returns the
|
||||
* stored record.
|
||||
*/
|
||||
fun trust(address: String, port: Int, name: String, fpHex: String, paired: Boolean): KnownHost {
|
||||
val existing = get(address, port)
|
||||
val host = existing?.copy(name = name, fpHex = fpHex, paired = paired)
|
||||
?: KnownHost(address, port, name, fpHex, paired)
|
||||
save(host)
|
||||
return host
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,30 +113,45 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
}
|
||||
|
||||
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||
fun rename(address: String, port: Int, newName: String) {
|
||||
val h = get(address, port) ?: return
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||
*/
|
||||
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||
save(updated)
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).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() }
|
||||
fun all(): List<KnownHost> = prefs.all
|
||||
.filterKeys { it != K_SCHEMA }
|
||||
.values
|
||||
.mapNotNull { (it as? String)?.let(::parse) }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
|
||||
/**
|
||||
* One-time move from the `"address:port"`-keyed schema to id-keyed records, run on first
|
||||
* construction after the upgrade and never again ([K_SCHEMA] records that it happened).
|
||||
*
|
||||
* It is deliberately ONE pass, not three: the store is being rewritten anyway, and every extra
|
||||
* migration pass is another chance to strand somebody's hosts. So the same pass mints the
|
||||
* stable id, re-keys the record onto it, and copies the retiring GLOBAL clipboard-sync setting
|
||||
* onto every host — behaviour-preserving, since every host was following that one value.
|
||||
*/
|
||||
private fun migrateIfNeeded(context: Context) {
|
||||
if (prefs.getInt(K_SCHEMA, 0) >= SCHEMA_VERSION) return
|
||||
val settings =
|
||||
context.applicationContext.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
|
||||
val result = migrate(prefs.all, settings.getBoolean(K_GLOBAL_CLIPBOARD_SYNC, true))
|
||||
// `commit`, not `apply`: the re-keyed records and the schema flag are one atomic write to
|
||||
// disk, and the global below is only retired once that write has landed. With `apply` a
|
||||
// process death in between could drop the old global while the hosts that were supposed to
|
||||
// inherit it were still only in memory. Once, on one small file, on an upgrade.
|
||||
val written = prefs.edit().apply {
|
||||
result.removals.forEach(::remove)
|
||||
result.writes.forEach { (k, v) -> putString(k, v) }
|
||||
putInt(K_SCHEMA, SCHEMA_VERSION)
|
||||
}.commit()
|
||||
if (written && settings.contains(K_GLOBAL_CLIPBOARD_SYNC)) {
|
||||
settings.edit().remove(K_GLOBAL_CLIPBOARD_SYNC).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(s: String): KnownHost? = runCatching {
|
||||
val j = JSONObject(s)
|
||||
@@ -97,10 +162,66 @@ class KnownHostStore(context: Context) {
|
||||
fpHex = j.getString("fp"),
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
// A record without an id can only be one this build wrote before the migration ran, or
|
||||
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
|
||||
id = j.optString("id", "").ifEmpty { newRecordId() },
|
||||
clipboardSync = j.optBoolean("clip", true),
|
||||
profileId = j.optString("profile", "").ifEmpty { null },
|
||||
pinnedProfileIds = stringList(j.optJSONArray("pins")),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
/** The prefs file holding the host records. */
|
||||
private const val PREFS_HOSTS = "punktfunk_hosts"
|
||||
|
||||
/** The app's settings file — read once by [migrate] for the retiring global. */
|
||||
private const val PREFS_SETTINGS = "punktfunk_settings"
|
||||
|
||||
/**
|
||||
* The global clipboard-sync key this migration retires. Clipboard sync is a decision about
|
||||
* a HOST (design §3, tier H), so it lives on the record now; the global is read once, to
|
||||
* seed every host, and then deleted.
|
||||
*/
|
||||
private const val K_GLOBAL_CLIPBOARD_SYNC = "clipboard_sync"
|
||||
|
||||
/** Schema marker inside the hosts file. Reserved — never a host record. */
|
||||
private const val K_SCHEMA = "__schema"
|
||||
|
||||
/** 1 = id-keyed records with per-host clipboard sync, profile binding and pins. */
|
||||
private const val SCHEMA_VERSION = 1
|
||||
|
||||
/** What [migrate] decided: entries to write, and (old) keys to drop. */
|
||||
data class Migration(val writes: Map<String, String>, val removals: Set<String>)
|
||||
|
||||
/**
|
||||
* The pure half of the store migration, over a raw prefs snapshot ([entries] as returned by
|
||||
* `SharedPreferences.all`) — so it can be tested against a real pre-migration blob without
|
||||
* an Android runtime.
|
||||
*
|
||||
* Every host record survives with its address, port, name, pin, paired flag and MACs
|
||||
* intact, gains a minted [KnownHost.id], moves to that key, and takes
|
||||
* [globalClipboardSync] as its own [KnownHost.clipboardSync]. Entries that aren't parsable
|
||||
* host records are left alone: they were already invisible (`all()` skipped them), and
|
||||
* deleting things we don't understand is not this pass's job.
|
||||
*/
|
||||
fun migrate(entries: Map<String, Any?>, globalClipboardSync: Boolean): Migration {
|
||||
val writes = mutableMapOf<String, String>()
|
||||
val removals = mutableSetOf<String>()
|
||||
for ((key, raw) in entries) {
|
||||
if (key == K_SCHEMA) continue
|
||||
val json = (raw as? String)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: continue
|
||||
if (!json.has("addr") || !json.has("port")) continue
|
||||
val id = json.optString("id", "").ifEmpty { newRecordId() }
|
||||
json.put("id", id)
|
||||
json.put("clip", json.optBoolean("clip", globalClipboardSync))
|
||||
writes[id] = json.toString()
|
||||
if (key != id) removals += key
|
||||
}
|
||||
return Migration(writes, removals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||
@@ -116,5 +237,31 @@ class KnownHostStore(context: Context) {
|
||||
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored JSON for one record — also the shape [migrate] upgrades into. */
|
||||
internal fun encode(host: KnownHost): String = JSONObject()
|
||||
.put("id", host.id)
|
||||
.put("addr", host.address)
|
||||
.put("port", host.port)
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
.put("clip", host.clipboardSync)
|
||||
.put("profile", host.profileId ?: "")
|
||||
.put("pins", JSONArray(host.pinnedProfileIds))
|
||||
.toString()
|
||||
|
||||
private fun stringList(a: JSONArray?): List<String> {
|
||||
if (a == null) return emptyList()
|
||||
return (0 until a.length()).mapNotNull { a.optString(it, "").ifEmpty { null } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh stable record identity: a lowercase UUID v4, the shape the Apple client's `StoredHost.id`
|
||||
* and the Rust `KnownHost.id` already use, so a `punktfunk://` host reference is one grammar
|
||||
* everywhere.
|
||||
*/
|
||||
fun newRecordId(): String = UUID.randomUUID().toString()
|
||||
|
||||
+108
@@ -1,6 +1,10 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||
@@ -31,3 +35,107 @@ class KnownHostStoreTest {
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The store migration, run against a REAL pre-migration prefs blob — records exactly as the
|
||||
* `"address:port"`-keyed store wrote them, IPv6 and all. It runs once against live user data on
|
||||
* every upgraded install, so the property under test is blunt: **every host survives**, with its
|
||||
* trust intact and the retiring global clipboard setting carried onto it.
|
||||
*/
|
||||
class KnownHostMigrationTest {
|
||||
/** Verbatim shape of the old writer: no `id`, no `clip`, MACs as a comma-joined string. */
|
||||
private fun legacy(addr: String, port: Int, name: String, fp: String, paired: Boolean, mac: String) =
|
||||
JSONObject()
|
||||
.put("addr", addr)
|
||||
.put("port", port)
|
||||
.put("name", name)
|
||||
.put("fp", fp)
|
||||
.put("paired", paired)
|
||||
.put("mac", mac)
|
||||
.toString()
|
||||
|
||||
private val preMigration: Map<String, Any?> = mapOf(
|
||||
"192.168.1.42:9777" to legacy("192.168.1.42", 9777, "Living Room PC", "a".repeat(64), true, "aa:bb:cc:dd:ee:ff"),
|
||||
"192.168.1.50:9777" to legacy("192.168.1.50", 9777, "Office", "b".repeat(64), false, ""),
|
||||
// An IPv6 host: the old key contains colons of its own, which is exactly why the record
|
||||
// always carried its address in the VALUE rather than parsing it back out of the key.
|
||||
"fd00::1:9777" to legacy("fd00::1", 9777, "Basement", "c".repeat(64), true, ""),
|
||||
)
|
||||
|
||||
private fun migrated(globalClipboard: Boolean): List<JSONObject> =
|
||||
KnownHostStore.migrate(preMigration, globalClipboard).writes.values.map { JSONObject(it) }
|
||||
|
||||
@Test
|
||||
fun everyHostSurvivesWithItsTrustIntact() {
|
||||
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
|
||||
assertEquals(3, result.writes.size)
|
||||
// Every old key is dropped — none of them is a valid new key (they aren't ids).
|
||||
assertEquals(preMigration.keys, result.removals)
|
||||
|
||||
val byName = result.writes.values.map { JSONObject(it) }.associateBy { it.getString("name") }
|
||||
assertEquals(setOf("Living Room PC", "Office", "Basement"), byName.keys)
|
||||
|
||||
val living = byName.getValue("Living Room PC")
|
||||
assertEquals("192.168.1.42", living.getString("addr"))
|
||||
assertEquals(9777, living.getInt("port"))
|
||||
assertEquals("a".repeat(64), living.getString("fp"))
|
||||
assertTrue(living.getBoolean("paired"))
|
||||
assertEquals("aa:bb:cc:dd:ee:ff", living.getString("mac"))
|
||||
|
||||
// The IPv6 address round-trips out of the value, untouched by the key rewrite.
|
||||
assertEquals("fd00::1", byName.getValue("Basement").getString("addr"))
|
||||
assertFalse(byName.getValue("Office").getBoolean("paired"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachRecordIsRekeyedOntoItsOwnMintedId() {
|
||||
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
|
||||
// The key IS the record's id, and the ids are distinct — two hosts must never collide onto
|
||||
// one record (which would silently lose one of them).
|
||||
result.writes.forEach { (key, json) -> assertEquals(key, JSONObject(json).getString("id")) }
|
||||
assertEquals(3, result.writes.keys.size)
|
||||
// The minted shape is the cross-platform one: a lowercase UUID.
|
||||
result.writes.keys.forEach { id ->
|
||||
assertEquals(36, id.length)
|
||||
assertEquals(id.lowercase(), id)
|
||||
assertEquals(listOf(8, 4, 4, 4, 12), id.split("-").map { it.length })
|
||||
}
|
||||
assertNotEquals(newRecordId(), newRecordId())
|
||||
}
|
||||
|
||||
/**
|
||||
* The behaviour-preserving half: clipboard sync was one global that every host followed, so
|
||||
* after the migration every host must still be following the value that global held — on or
|
||||
* off. This is the assertion that would catch a migration silently defaulting everyone to on.
|
||||
*/
|
||||
@Test
|
||||
fun theRetiringGlobalClipboardSettingLandsOnEveryHost() {
|
||||
migrated(globalClipboard = true).forEach { assertTrue(it.getBoolean("clip")) }
|
||||
migrated(globalClipboard = false).forEach { assertFalse(it.getBoolean("clip")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationIsIdempotentOverItsOwnOutput() {
|
||||
val once = KnownHostStore.migrate(preMigration, globalClipboardSync = false)
|
||||
val twice = KnownHostStore.migrate(once.writes, globalClipboardSync = true)
|
||||
// Already-keyed records keep their ids and their per-host value: a second pass (a downgrade
|
||||
// then re-upgrade, a schema flag lost) must not re-mint ids that bindings point at, and must
|
||||
// not resurrect the global over a per-host answer the user has since changed.
|
||||
assertEquals(once.writes, twice.writes)
|
||||
assertTrue(twice.removals.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entriesThatArentHostRecordsAreLeftAlone() {
|
||||
val junk = mapOf(
|
||||
"some_unrelated_flag" to true,
|
||||
"half_a_record" to JSONObject().put("name", "no address").toString(),
|
||||
"not_even_json" to "{{{",
|
||||
)
|
||||
val result = KnownHostStore.migrate(junk, globalClipboardSync = true)
|
||||
// They were already invisible to `all()`; deleting what we don't understand isn't this
|
||||
// pass's job, and writing them as hosts would invent records out of nothing.
|
||||
assertTrue(result.writes.isEmpty())
|
||||
assertTrue(result.removals.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user