feat(android): pairing/identity — persistent identity, TOFU pinning, SPAKE2 PIN ceremony
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 28s
android / android (push) Failing after 1m55s
ci / docs-site (push) Successful in 33s
ci / bench (push) Successful in 1m45s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m9s
docker / deploy-docs (push) Successful in 18s

M4 Android stage 1 (trust). The client now presents a persistent self-signed identity on
every connect, pins host certs trust-on-first-use, and runs the SPAKE2 PIN pairing
ceremony — parity with the Apple/Linux clients. The Rust connector already exposed this;
this wires it through the JNI + a Keystore-backed Kotlin store + the connect UI.

- crates/punktfunk-android: nativeGenerateIdentity (mint), nativeConnect gains
  certPem/keyPem/pinHex (identity + TOFU/pinned), nativeHostFingerprint, nativePair
  (SPAKE2). hex32/parse_hex32 helpers.
- kit/security: IdentityStore (AndroidKeyStore AES-256-GCM-wrapped PEM blob; StrongBox
  with TEE fallback; four-state load so a decrypt failure never shadow-mints), PinStore
  (host-id -> fp-hex in SharedPreferences). obtainIdentity mints once on genuine first run.
- app: ConnectScreen loads/mints the identity, looks up the stored pin, and gates connect
  on a trust decision — TOFU prompt (first connect), fingerprint-changed warning, PIN dialog.
- AndroidManifest: allowBackup=false (Keystore keys don't restore; a restored device
  re-mints rather than carrying a dead blob).

Verified live (emulator -> home-worker-2, synthetic m3-host):
- identity: host logs the presented client fingerprint; stable across an app restart.
- TOFU: first-connect prompt -> Trust -> pins the observed host fp -> pinned reconnect
  skips the prompt.
- SPAKE2: PIN ceremony -> "pairing complete — client trusted" -> auto-connect under
  --require-pairing; wrong PIN / host down -> "Pairing failed".

Known follow-up: trust is keyed by mDNS instance id for discovered hosts but by
"host:port" for manually-typed ones, so pairing via one path isn't recognized by the other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:28:58 +02:00
parent 802e98d3a3
commit b0df291ffe
6 changed files with 568 additions and 22 deletions
@@ -26,7 +26,7 @@
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
@@ -22,10 +23,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
@@ -33,9 +36,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -55,6 +60,10 @@ import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
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.obtainIdentity
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -136,6 +145,18 @@ private sealed interface Screen {
data class Stream(val handle: Long) : Screen
}
/** A trust decision awaiting the user before a connect proceeds. [hostId] is the PinStore key. */
private data class PendingTrust(
val host: String,
val port: Int,
val hostId: String,
val advertisedFp: String?,
val pairingRequired: Boolean,
val kind: Kind,
) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
}
@Composable
private fun App() {
var screen by remember { mutableStateOf<Screen>(Screen.Connect) }
@@ -169,24 +190,82 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
}
}
fun connect(targetHost: String, targetPort: Int) {
val identityStore = remember { IdentityStore(context) }
val pinStore = remember { PinStore(context) }
// 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<ClientIdentity?>(null) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { obtainIdentity(identityStore) } }
.onSuccess { identity = it }
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
}
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
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),
// persist the fingerprint the host presented so the next connect goes straight through.
fun doConnect(targetHost: String, targetPort: Int, hostId: String, pinHex: String?) {
val id = identity
if (id == null) {
status = "Identity not ready yet — try again in a moment"
return
}
connecting = true
status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(targetHost, targetPort, w, h, hz)
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "",
)
}
connecting = false
if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed
val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) pinStore.pin(hostId, fp)
}
onConnected(handle)
} else {
status = "Connection failed — check host/port and logcat"
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start()
}
}
}
// Decide TOFU vs pinned vs pairing before connecting.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
val hostId = hostIdFor(targetHost, targetPort, dh)
val stored = pinStore.get(hostId)
val pairingReq = dh?.pairingRequired ?: false
when {
stored != null -> {
val adv = dh?.fingerprint?.lowercase()
if (adv != null && adv != stored) {
// Advertised fp no longer matches the pin — host reinstall, or an impostor.
pendingTrust = PendingTrust(
targetHost, targetPort, hostId, adv, pairingReq, PendingTrust.Kind.FP_CHANGED,
)
} else {
doConnect(targetHost, targetPort, hostId, stored)
}
}
// Never trusted + host requires pairing → TOFU can't pass the gate; go straight to PIN.
pairingReq -> pendingTrust = PendingTrust(
// pairingReq true ⇒ dh != null (smart-cast), so the fp is the advertised one.
targetHost, targetPort, hostId, dh.fingerprint, true, PendingTrust.Kind.PAIR,
)
// Never trusted, TOFU allowed → confirm trust first.
else -> pendingTrust = PendingTrust(
targetHost, targetPort, hostId, dh?.fingerprint, false, PendingTrust.Kind.TRUST_NEW,
)
}
}
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -204,7 +283,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
DiscoveredHostRow(dh, enabled = !connecting) {
host = dh.host
port = dh.port.toString()
connect(dh.host, dh.port)
connect(dh.host, dh.port, dh)
}
}
}
@@ -239,6 +318,118 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
Spacer(Modifier.height(24.dp))
Text("core ABI v$abi", style = MaterialTheme.typography.labelSmall)
}
pendingTrust?.let { pt ->
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text("Pairing with a PIN is stronger — it verifies both sides.")
}
},
confirmButton = {
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.hostId, null) }) {
Text("Trust (TOFU)")
}
},
dismissButton = {
Row {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
Text("Pair with PIN…")
}
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
)
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair, " +
"or forget the saved fingerprint to trust the new one.",
)
},
confirmButton = {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
},
dismissButton = {
Row {
TextButton({
pinStore.remove(pt.hostId)
pendingTrust = null
doConnect(pt.host, pt.port, pt.hostId, null)
}) { Text("Forget & re-TOFU") }
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
)
PendingTrust.Kind.PAIR -> {
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!pairing) pendingTrust = null },
title = { Text("Pair with PIN") },
text = {
Column {
Text("Enter the 4-digit PIN shown on the host.")
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { Text("PIN") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("This device") },
singleLine = true,
)
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(
enabled = !pairing && pin.length == 4 && identity != null,
onClick = {
val id = identity
if (id != null) {
pairing = true
err = null
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
)
}
pairing = false
if (fp.isNotEmpty()) {
pinStore.pin(pt.hostId, fp) // verified host fp; paired
pendingTrust = null
doConnect(pt.host, pt.port, pt.hostId, fp)
} else {
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
},
) { Text(if (pairing) "Pairing…" else "Pair") }
},
dismissButton = {
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
},
)
}
}
}
}
@Composable
@@ -18,13 +18,44 @@ object NativeBridge {
external fun coreVersion(): String
/**
* Connect to a host (trust-on-first-use, anonymous) and return an opaque session handle, or
* `0` on failure. Pair the handle with exactly one [nativeClose].
*
* TODO(M4): pin/identity/pairing, plane pumps (video/audio/rumble/HID), input, mode
* renegotiation — see `crates/punktfunk-android/src/session.rs`.
* Mint a fresh persistent self-signed identity, returned as
* `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on error. Kotlin persists it
* (Keystore-wrapped via `IdentityStore`) and only calls this again when the store is empty.
*/
external fun nativeConnect(host: String, port: Int, width: Int, height: Int, refreshHz: Int): Long
external fun nativeGenerateIdentity(): String
/**
* Connect, presenting [certPem]/[keyPem] (both empty = anonymous) and pinning [pinHex] (empty =
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
* `0`). Returns an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
*/
external fun nativeConnect(
host: String,
port: Int,
width: Int,
height: Int,
refreshHz: Int,
certPem: String,
keyPem: String,
pinHex: String,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
external fun nativeHostFingerprint(handle: Long): String
/**
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
* Blocking — call off the main thread.
*/
external fun nativePair(
host: String,
port: Int,
certPem: String,
keyPem: String,
pin: String,
name: String,
): String
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long)
@@ -0,0 +1,151 @@
package io.unom.punktfunk.kit.security
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Log
import io.unom.punktfunk.kit.NativeBridge
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
private const val TAG = "PunktfunkIdentity"
/** The delimiter the JNI uses to join the two PEMs; collision-free (PEM bodies never contain it). */
private const val PEM_DELIM = "\n-----PUNKTFUNK-KEY-----\n"
/** This device's persistent punktfunk identity (presented to hosts via TLS client auth). */
data class ClientIdentity(val certPem: String, val privateKeyPem: String)
/** Result of [IdentityStore.load] — four states so the caller never mints over a *recoverable* error. */
sealed interface IdentityLoad {
data class Ok(val identity: ClientIdentity) : IdentityLoad
/** Genuine first run (no blob on disk) — mint a new identity here, and only here. */
object Absent : IdentityLoad
/** A blob exists but can't be decrypted (Keystore key gone, corruption). NEVER shadow-mint. */
data class Unrecoverable(val reason: String, val cause: Throwable?) : IdentityLoad
}
class IdentityUnrecoverableException(message: String, cause: Throwable?) : Exception(message, cause)
/** Split the JNI's joined "<cert>\n-----PUNKTFUNK-KEY-----\n<key>" blob; `null` if malformed. */
fun splitGenerated(joined: String): ClientIdentity? {
val i = joined.indexOf(PEM_DELIM)
if (i < 0) return null
return ClientIdentity(
certPem = joined.substring(0, i),
privateKeyPem = joined.substring(i + PEM_DELIM.length),
)
}
/**
* Load the device identity, minting *once* on genuine first run. NEVER mints over an error state:
* an [IdentityLoad.Unrecoverable] surfaces as a throw so the UI can tell the user (re-pair) rather
* than silently swapping in a new identity (which would change our fingerprint everywhere).
*/
fun obtainIdentity(store: IdentityStore): ClientIdentity =
when (val r = store.load()) {
is IdentityLoad.Ok -> r.identity
IdentityLoad.Absent -> {
val joined = NativeBridge.nativeGenerateIdentity()
val id = splitGenerated(joined)
?: throw IdentityUnrecoverableException("nativeGenerateIdentity returned empty", null)
store.persist(id)
id
}
is IdentityLoad.Unrecoverable ->
throw IdentityUnrecoverableException(r.reason, r.cause)
}
/**
* Persists the identity PEM blob to app-private storage, wrapped with an AndroidKeyStore AES-256-GCM
* key (never exportable; StrongBox-backed where available, TEE otherwise). On-disk layout:
* `[12-byte IV][GCM ciphertext+tag]`. The wrapping key never leaves the secure element, and Keystore
* keys don't survive backup/restore — so a restored device reads [IdentityLoad.Absent] (the blob is
* excluded from backup; see the manifest) and re-mints, rather than carrying a dead identity.
*/
class IdentityStore(context: Context) {
private val appCtx = context.applicationContext
private val file = File(appCtx.filesDir, "pf_identity.bin")
private val alias = "punktfunk_identity_v1"
fun load(): IdentityLoad {
if (!file.exists()) return IdentityLoad.Absent
return try {
val blob = file.readBytes()
if (blob.size <= IV_LEN) {
return IdentityLoad.Unrecoverable("identity blob truncated (${blob.size} B)", null)
}
val key = (keyStore().getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
?: return IdentityLoad.Unrecoverable("blob present but Keystore key missing", null)
val iv = blob.copyOfRange(0, IV_LEN)
val ct = blob.copyOfRange(IV_LEN, blob.size)
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
val plain = String(cipher.doFinal(ct), Charsets.UTF_8)
splitGenerated(plain)?.let { IdentityLoad.Ok(it) }
?: IdentityLoad.Unrecoverable("decrypted identity blob malformed", null)
} catch (e: Exception) {
// Decrypt/Keystore failure: the identity is unrecoverable. Do NOT mint a shadow identity.
Log.e(TAG, "identity load failed", e)
IdentityLoad.Unrecoverable("identity decrypt failed: ${e.javaClass.simpleName}", e)
}
}
fun persist(identity: ClientIdentity) {
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv // GCM: a fresh random 12-byte IV per encryption
val plain = (identity.certPem + PEM_DELIM + identity.privateKeyPem).toByteArray(Charsets.UTF_8)
val ct = cipher.doFinal(plain)
// Write to a temp file then rename, so a crash mid-write can't leave a torn (unrecoverable) blob.
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeBytes(iv + ct)
if (!tmp.renameTo(file)) {
file.writeBytes(iv + ct)
tmp.delete()
}
}
private fun keyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private fun getOrCreateKey(): SecretKey {
val ks = keyStore()
(ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
// Prefer a StrongBox-backed key; fall back to TEE where StrongBox is absent (e.g. the emulator).
return try {
generateKey(strongBox = true)
} catch (e: StrongBoxUnavailableException) {
Log.i(TAG, "StrongBox unavailable — using TEE-backed key", e)
generateKey(strongBox = false)
}
}
private fun generateKey(strongBox: Boolean): SecretKey {
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setIsStrongBoxBacked(strongBox)
.build()
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
kg.init(spec)
return kg.generateKey()
}
private companion object {
const val TRANSFORM = "AES/GCM/NoPadding"
const val IV_LEN = 12
const val GCM_TAG_BITS = 128
}
}
@@ -0,0 +1,27 @@
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()
}
}