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:
@@ -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