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
ci / docs-site (push) Successful in 33s
ci / bench (push) Successful in 1m45s
android / android (push) Failing after 1m55s
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" /> <uses-feature android:name="android.hardware.gamepad" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon" android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
@@ -1,5 +1,6 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -33,9 +36,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost 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.IdentityStore
import io.unom.punktfunk.kit.security.PinStore
import io.unom.punktfunk.kit.security.obtainIdentity
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -136,6 +145,18 @@ private sealed interface Screen {
data class Stream(val handle: Long) : 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 @Composable
private fun App() { private fun App() {
var screen by remember { mutableStateOf<Screen>(Screen.Connect) } 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 connecting = true
status = "Connecting to $targetHost:$targetPort" status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch { scope.launch {
val handle = withContext(Dispatchers.IO) { 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 connecting = false
if (handle != 0L) { 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) onConnected(handle)
} else { } else {
status = "Connection failed — check host/port and logcat" status = "Connection failed — check host/port, PIN, and logcat"
discovery.start() 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( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -204,7 +283,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
DiscoveredHostRow(dh, enabled = !connecting) { DiscoveredHostRow(dh, enabled = !connecting) {
host = dh.host host = dh.host
port = dh.port.toString() 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)) Spacer(Modifier.height(24.dp))
Text("core ABI v$abi", style = MaterialTheme.typography.labelSmall) 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 @Composable
@@ -18,13 +18,44 @@ object NativeBridge {
external fun coreVersion(): String external fun coreVersion(): String
/** /**
* Connect to a host (trust-on-first-use, anonymous) and return an opaque session handle, or * Mint a fresh persistent self-signed identity, returned as
* `0` on failure. Pair the handle with exactly one [nativeClose]. * `"<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.
* TODO(M4): pin/identity/pairing, plane pumps (video/audio/rumble/HID), input, mode
* renegotiation — see `crates/punktfunk-android/src/session.rs`.
*/ */
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`. */ /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long) 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()
}
}
+157 -11
View File
@@ -4,12 +4,14 @@
//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode //! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle. //! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle.
//! //!
//! Wired so far: connect/close + the video plane (HEVC `next_frame` → NDK AMediaCodec → the //! Wired: connect/close, the video plane (HEVC `next_frame` → NDK AMediaCodec → the SurfaceView's
//! SurfaceView's `ANativeWindow`, see [`crate::decode`]). //! `ANativeWindow`, see [`crate::decode`]), host→client audio ([`crate::audio`]), input
//! (`send_input` — mouse/keyboard/gamepad), rumble/DualSense HID feedback ([`crate::feedback`]),
//! and the trust surface: `nativeGenerateIdentity` (persistent identity, Keystore-wrapped on the
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
//! //!
//! TODO(M4 Android stage 1): audio (`next_audio` → Opus → Oboe), input (`send_input` / //! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! `send_rich_input`), rumble/HID feedback, pairing/identity (Keystore). Port the orchestration //! renegotiation. Port the remaining orchestration from `crates/punktfunk-client-linux`.
//! from `crates/punktfunk-client-linux`.
use jni::objects::{JObject, JString}; use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong}; use jni::sys::{jboolean, jint, jlong};
@@ -65,9 +67,54 @@ impl Drop for SessionHandle {
} }
} }
/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long` — trust-on-first-use, /// SHA-256 fingerprint → 64 lowercase hex chars (matches the host log + client-rs).
/// anonymous. Returns an opaque session handle, or `0` on failure (logged to logcat). fn hex32(fp: &[u8; 32]) -> String {
use std::fmt::Write;
fp.iter().fold(String::with_capacity(64), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// 64-hex → [u8; 32]; `None` on bad length/char.
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
let out = match punktfunk_core::quic::endpoint::generate_identity() {
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
Err(e) => {
log::error!("nativeGenerateIdentity failed: {e}");
String::new()
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex): Long`. `certPem`/
/// `keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU
/// (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
/// Returns an opaque handle, or 0 on failure (logged).
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>, mut env: JNIEnv<'local>,
_this: JObject<'local>, _this: JObject<'local>,
@@ -76,11 +123,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
width: jint, width: jint,
height: jint, height: jint,
refresh_hz: jint, refresh_hz: jint,
cert_pem: JString<'local>,
key_pem: JString<'local>,
pin_hex: JString<'local>,
) -> jlong { ) -> jlong {
let host: String = match env.get_string(&host) { let host: String = match env.get_string(&host) {
Ok(s) => s.into(), Ok(s) => s.into(),
Err(_) => return 0, Err(_) => return 0,
}; };
let cert: String = env
.get_string(&cert_pem)
.map(Into::into)
.unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None
} else {
Some((cert, key))
};
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
None
} else {
match parse_hex32(&pin_hex) {
Some(fp) => Some(fp),
None => {
log::error!("nativeConnect: bad pin hex (len {})", pin_hex.len());
return 0;
}
}
};
let mode = Mode { let mode = Mode {
width: width as u32, width: width as u32,
height: height as u32, height: height as u32,
@@ -92,10 +165,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
mode, mode,
CompositorPref::Auto, CompositorPref::Auto,
GamepadPref::Auto, GamepadPref::Auto,
0, // bitrate_kbps: host default 0, // bitrate_kbps: host default
None, // launch: default app None, // launch: default app
None, // pin: trust on first use pin, // Some → Crypto on host-fp mismatch
None, // identity: anonymous (TODO: Keystore-backed identity + pairing) identity, // owned (cert, key) PEM, or None (anonymous)
Duration::from_secs(10), Duration::from_secs(10),
) { ) {
Ok(client) => { Ok(client) => {
@@ -132,6 +205,79 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
} }
} }
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
let out = if handle == 0 {
String::new()
} else {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
hex32(&h.client.host_fingerprint)
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
cert_pem: JString<'local>,
key_pem: JString<'local>,
pin: JString<'local>,
name: JString<'local>,
) -> jni::sys::jstring {
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
e.get_string(j).map(Into::into).unwrap_or_default()
};
let host = g(&mut env, &host);
let cert = g(&mut env, &cert_pem);
let key = g(&mut env, &key_pem);
let pin = g(&mut env, &pin);
let name = g(&mut env, &name);
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
log::error!("nativePair to {host}:{port} failed: {e}");
String::new()
}
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an /// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started. /// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]