diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml
index 266c5f2..40d8aea 100644
--- a/clients/android/app/src/main/AndroidManifest.xml
+++ b/clients/android/app/src/main/AndroidManifest.xml
@@ -26,7 +26,7 @@
(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(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(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(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
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt
index 17311f3..dfe1b97 100644
--- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt
@@ -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
+ * `"\n-----PUNKTFUNK-KEY-----\n"`, 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)
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/IdentityStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/IdentityStore.kt
new file mode 100644
index 0000000..5fb28ef
--- /dev/null
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/IdentityStore.kt
@@ -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 "\n-----PUNKTFUNK-KEY-----\n" 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
+ }
+}
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt
new file mode 100644
index 0000000..d7da829
--- /dev/null
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/PinStore.kt
@@ -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()
+ }
+}
diff --git a/crates/punktfunk-android/src/session.rs b/crates/punktfunk-android/src/session.rs
index 1deb90d..87282aa 100644
--- a/crates/punktfunk-android/src/session.rs
+++ b/crates/punktfunk-android/src/session.rs
@@ -4,12 +4,14 @@
//! 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.
//!
-//! Wired so far: connect/close + the video plane (HEVC `next_frame` → NDK AMediaCodec → the
-//! SurfaceView's `ANativeWindow`, see [`crate::decode`]).
+//! Wired: connect/close, the video plane (HEVC `next_frame` → NDK AMediaCodec → the SurfaceView's
+//! `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` /
-//! `send_rich_input`), rumble/HID feedback, pairing/identity (Keystore). Port the orchestration
-//! from `crates/punktfunk-client-linux`.
+//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
+//! renegotiation. Port the remaining orchestration from `crates/punktfunk-client-linux`.
use jni::objects::{JObject, JString};
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,
-/// anonymous. Returns an opaque session handle, or `0` on failure (logged to logcat).
+/// SHA-256 fingerprint → 64 lowercase hex chars (matches the host log + client-rs).
+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 `"\n-----PUNKTFUNK-KEY-----\n"`, or `""` on failure (logged). Kotlin
+/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[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>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -76,11 +123,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
width: jint,
height: jint,
refresh_hz: jint,
+ cert_pem: JString<'local>,
+ key_pem: JString<'local>,
+ pin_hex: JString<'local>,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
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 {
width: width as u32,
height: height as u32,
@@ -92,10 +165,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
mode,
CompositorPref::Auto,
GamepadPref::Auto,
- 0, // bitrate_kbps: host default
- None, // launch: default app
- None, // pin: trust on first use
- None, // identity: anonymous (TODO: Keystore-backed identity + pairing)
+ 0, // bitrate_kbps: host default
+ None, // launch: default app
+ pin, // Some → Crypto on host-fp mismatch
+ identity, // owned (cert, key) PEM, or None (anonymous)
Duration::from_secs(10),
) {
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
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
#[cfg(target_os = "android")]