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
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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user