From e9d4374dccec59cdf7f14e6f7da3a9b678fb9282 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:01:09 +0000 Subject: [PATCH 2/4] feat(netcode): persist mesh CA key + credential offline (cache from last enrollment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the offline-trust half of netcode go-live. The two trust inputs MeshNode.build needs were populated only during ONLINE server enrollment and held in memory, so an offline-first band mesh could never bring up the netcode after a restart. Now: • NetcodeTrust caches the pinned Ed25519 mesh-CA key durably (write-through in pinMeshCa, restore in attach()) so the pin survives a restart. • MeshRepository persists this device's own mesh-CA credential per mesh in applyPulledCredential and reloads it in setCanonicalMeshId, so its DeviceIdentity is available with no server. Both are re-validated by MeshTrustMaterial (device/mesh/expiry match) before a node is built. androidApp is not compilable in this headless env — delivered as a reviewed wiring patch; the pure gate is jvmTest-verified. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../com/aether/mofe/data/MeshRepository.kt | 25 ++++++++++++- .../mofe/platform/netcode/NetcodeTrust.kt | 35 +++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/androidApp/src/main/java/com/aether/mofe/data/MeshRepository.kt b/androidApp/src/main/java/com/aether/mofe/data/MeshRepository.kt index e78dfe1..e2a070f 100644 --- a/androidApp/src/main/java/com/aether/mofe/data/MeshRepository.kt +++ b/androidApp/src/main/java/com/aether/mofe/data/MeshRepository.kt @@ -16,6 +16,7 @@ import com.aether.mofe.platform.band.BandNodeRecord 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 @@ -32,6 +33,7 @@ import kotlinx.coroutines.launch 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 @@ -248,6 +250,9 @@ class MeshRepository( 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 @@ -1139,6 +1144,7 @@ class MeshRepository( _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() } @@ -1259,10 +1265,27 @@ class MeshRepository( 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) } } \ No newline at end of file diff --git a/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt index 8ca7ab1..7d72c2c 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt @@ -1,5 +1,6 @@ package com.aether.mofe.platform.netcode +import android.content.SharedPreferences import android.util.Base64 import android.util.Log import com.aether.mofe.messaging.MeshCodec @@ -28,22 +29,50 @@ import com.aether.mofe.platform.identity.RawSeedProvider */ 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. */ -- 2.43.0