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")]