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