User Story #47 » 0002-feat-netcode-persist-mesh-CA-key-credential-offline-.patch
| androidApp/src/main/java/com/aether/mofe/data/MeshRepository.kt | ||
|---|---|---|
|
import com.aether.mofe.platform.band.BandPredicateRecord
|
||
|
import com.aether.mofe.platform.band.BroadcastBand
|
||
|
import com.aether.mofe.platform.band.SharedMeshState
|
||
|
import com.aether.mofe.platform.netcode.NetcodeTrust
|
||
|
import kotlinx.coroutines.CoroutineScope
|
||
|
import kotlinx.coroutines.Dispatchers
|
||
|
import kotlinx.coroutines.Job
|
||
| ... | ... | |
|
import kotlinx.serialization.builtins.ListSerializer
|
||
|
import kotlinx.serialization.json.Json
|
||
|
import kotlinx.serialization.json.JsonObject
|
||
|
import kotlinx.serialization.json.jsonObject
|
||
|
import kotlin.math.atan2
|
||
|
import kotlin.math.cos
|
||
|
import kotlin.math.hypot
|
||
| ... | ... | |
|
runCatching {
|
||
|
ensureX25519()
|
||
|
loadMemberships()
|
||
|
// Restore the pinned mesh CA key (cache from last enrollment) so the netcode can be
|
||
|
// constructed offline after a restart — see NetcodeTrust.attach / MeshTrustMaterial.
|
||
|
appContext?.let { NetcodeTrust.attach(it.getSharedPreferences("aether_netcode_trust", Context.MODE_PRIVATE)) }
|
||
|
// A used UWB scope is dead, so a recycled link reopens on a FRESH scope
|
||
|
// with a NEW local address. Drop that peer's cached advertised address so
|
||
|
// the next tick re-mints it (see maybeOpenUwbSessions) and re-broadcasts
|
||
| ... | ... | |
|
_activeMeshId.value = meshId
|
||
|
selfContract = selfContract.copy(meshId = meshId)
|
||
|
loadMeshKey(meshId) // restore a previously-adopted group key for this mesh
|
||
|
loadPersistedCredential(meshId) // restore this device's cached mesh-CA credential (offline netcode)
|
||
|
saveMemberships() // persist which mesh is active
|
||
|
rebuild()
|
||
|
}
|
||
| ... | ... | |
|
selfControleeAddr = ""
|
||
|
}
|
||
|
// ── Netcode trust: cache this device's own mesh-CA credential (cache from last enrollment) ──
|
||
|
private fun credentialPrefs() =
|
||
|
appContext?.getSharedPreferences("aether_credentials", Context.MODE_PRIVATE)
|
||
|
/** Reload this device's persisted mesh-CA credential for [meshId] so the netcode has its
|
||
|
* DeviceIdentity offline after a restart. No-op if none is cached or one is already loaded. The
|
||
|
* MeshTrustMaterial readiness gate re-checks device/mesh/expiry before it's used to build a node. */
|
||
|
private fun loadPersistedCredential(meshId: String) {
|
||
|
if (_gatewayCredential.value != null || meshId.isBlank()) return
|
||
|
val raw = credentialPrefs()?.getString("cred_$meshId", null) ?: return
|
||
|
_gatewayCredential.value = runCatching { membershipJson.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||
|
}
|
||
|
/** A1 pull complete: arm the uplink and flip the status chip to Registered. */
|
||
|
fun applyPulledCredential(credential: JsonObject, ownerLabel: String) {
|
||
|
_gatewayCredential.value = credential
|
||
|
_regState.value = MeshRegState.Registered(ownerLabel, canonicalMeshId ?: selfContract.meshId)
|
||
|
// Persist THIS device's credential (cache from last enrollment) so it's available offline after
|
||
|
// a restart — reloaded by setCanonicalMeshId → loadPersistedCredential.
|
||
|
val meshId = canonicalMeshId ?: selfContract.meshId
|
||
|
if (meshId.isNotBlank()) credentialPrefs()?.edit { putString("cred_$meshId", credential.toString()) }
|
||
|
_regState.value = MeshRegState.Registered(ownerLabel, meshId)
|
||
|
}
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt | ||
|---|---|---|
|
package com.aether.mofe.platform.netcode
|
||
|
import android.content.SharedPreferences
|
||
|
import android.util.Base64
|
||
|
import android.util.Log
|
||
|
import com.aether.mofe.messaging.MeshCodec
|
||
| ... | ... | |
|
*/
|
||
|
object NetcodeTrust {
|
||
|
private const val TAG = "NetcodeTrust"
|
||
|
private const val K_CA_KEY = "mesh_ca_pub_b64"
|
||
|
private const val K_CA_KID = "mesh_ca_key_id"
|
||
|
@Volatile private var pinnedCaPublicKey: ByteArray? = null
|
||
|
@Volatile private var pinnedKeyId: String? = null
|
||
|
/** Durable store for the CA pin — set once at startup so the pin survives a restart (see [attach]). */
|
||
|
@Volatile private var store: SharedPreferences? = null
|
||
|
/**
|
||
|
* Attach durable storage and RESTORE any previously pinned CA key — the "cache from last
|
||
|
* enrollment" reload. Call once at startup (e.g. from the repository init, which holds a Context)
|
||
|
* BEFORE the first offline netcode bring-up. Without this, the CA pin lives only in memory and a
|
||
|
* process restart while offline loses it, so `MeshNode.build` could never be constructed without
|
||
|
* the server. Idempotent: a later online [pinMeshCa] write-through refreshes the stored key.
|
||
|
*/
|
||
|
fun attach(prefs: SharedPreferences) {
|
||
|
store = prefs
|
||
|
if (pinnedCaPublicKey == null) {
|
||
|
val b64 = prefs.getString(K_CA_KEY, null) ?: return
|
||
|
applyPin(b64, prefs.getString(K_CA_KID, null))
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Pin the mesh CA anchor fetched from the server. Idempotent; logs (does not silently accept) a
|
||
|
* changed key id, since a rotated CA key is a trust event the app should surface.
|
||
|
* Pin the mesh CA anchor fetched from the server, and write it through to durable storage so it is
|
||
|
* available offline after a restart. Idempotent; logs (does not silently accept) a changed key id,
|
||
|
* since a rotated CA key is a trust event the app should surface.
|
||
|
*/
|
||
|
fun pinMeshCa(meshCaPublicKeyB64: String, keyId: String?) {
|
||
|
val decoded = runCatching { Base64.decode(meshCaPublicKeyB64, Base64.DEFAULT) }.getOrNull() ?: return
|
||
|
if (!applyPin(meshCaPublicKeyB64, keyId)) return
|
||
|
store?.edit()?.putString(K_CA_KEY, meshCaPublicKeyB64)?.putString(K_CA_KID, keyId)?.apply()
|
||
|
}
|
||
|
/** Decode + set the in-memory pin (no persistence). Returns false if the key can't be decoded. */
|
||
|
private fun applyPin(meshCaPublicKeyB64: String, keyId: String?): Boolean {
|
||
|
val decoded = runCatching { Base64.decode(meshCaPublicKeyB64, Base64.DEFAULT) }.getOrNull() ?: return false
|
||
|
val prevId = pinnedKeyId
|
||
|
if (prevId != null && keyId != null && prevId != keyId) {
|
||
|
Log.w(TAG, "mesh CA key id changed ($prevId → $keyId) — CA rotated or endpoint switched")
|
||
|
}
|
||
|
pinnedCaPublicKey = decoded
|
||
|
pinnedKeyId = keyId
|
||
|
return true
|
||
|
}
|
||
|
/** The pinned Ed25519 mesh CA public key (raw bytes), or null until [pinMeshCa] has run. */
|
||