User Story #55 » 0019-feat-netcode-fetch-pin-the-mesh-CA-and-assemble-the-.patch
| androidApp/src/main/java/com/aether/mofe/platform/control/ControlPlaneAPi.kt | ||
|---|---|---|
|
import kotlinx.serialization.json.add
|
||
|
import kotlinx.serialization.json.buildJsonArray
|
||
|
import kotlinx.serialization.json.buildJsonObject
|
||
|
import kotlinx.serialization.json.contentOrNull
|
||
|
import kotlinx.serialization.json.jsonArray
|
||
|
import kotlinx.serialization.json.jsonObject
|
||
|
import kotlinx.serialization.json.jsonPrimitive
|
||
|
import kotlinx.serialization.json.put
|
||
|
/** Session-cookie authed control-plane client (manual serialization, ktor-core). */
|
||
| ... | ... | |
|
authed(); jsonBody(buildJsonObject { put("target_user_id", targetUserId) }.toString())
|
||
|
}.status.isSuccess()
|
||
|
/**
|
||
|
* Fetch the mesh CA trust anchor — GET /api/v1/mesh/ca → {alg, meshCaPublicKey, keyId}. Public
|
||
|
* (no auth); the device pins the returned Ed25519 key over this web-PKI-authenticated TLS
|
||
|
* channel so every peer link verifies against a single central CA (see the trust-root
|
||
|
* assessment). Returns (base64 public key, keyId) or null if unavailable.
|
||
|
*/
|
||
|
suspend fun fetchMeshCaPublicKey(): Pair<String, String?>? {
|
||
|
val r = http.get("$baseUrl/api/v1/mesh/ca")
|
||
|
if (!r.status.isSuccess()) return null
|
||
|
val o = json.parseToJsonElement(r.bodyAsText()).jsonObject
|
||
|
val key = o["meshCaPublicKey"]?.jsonPrimitive?.contentOrNull ?: return null
|
||
|
return key to o["keyId"]?.jsonPrimitive?.contentOrNull
|
||
|
}
|
||
|
suspend fun pullCredential(meshId: String, deviceId: String, pub: String): JsonObject? {
|
||
|
val r = http.get("$baseUrl/api/v1/mesh/$meshId/device/$deviceId/credential") {
|
||
|
authed(); parameter("pub", pub)
|
||
| androidApp/src/main/java/com/aether/mofe/platform/control/MeshRegistrar.kt | ||
|---|---|---|
|
import com.aether.mofe.platform.band.BandNodeRecord
|
||
|
import com.aether.mofe.platform.identity.DeviceIdentityProvider
|
||
|
import com.aether.mofe.platform.identity.RawSeedProvider
|
||
|
import com.aether.mofe.platform.netcode.NetcodeTrust
|
||
|
class MeshRegistrar(
|
||
|
private val api: ControlPlaneApi,
|
||
| ... | ... | |
|
if (cred == null) return false // mesh gone / not enrolled → caller clears stale state
|
||
|
repo.setCanonicalMeshId(meshId)
|
||
|
// Pin the mesh CA trust anchor for the netcode assembly, fetched over the server's TLS
|
||
|
// channel (the web-PKI bootstrap). Best-effort: a server without the endpoint, or offline,
|
||
|
// must not block enrollment — the mesh runs on the band regardless. NetcodeTrust then has
|
||
|
// both inputs MeshNodeDependencies needs (this pinned CA key + a DeviceIdentity built from
|
||
|
// the pulled credential); constructing the MeshNode itself remains gated on peer discovery.
|
||
|
runCatching {
|
||
|
api.fetchMeshCaPublicKey()?.let { (key, kid) -> NetcodeTrust.pinMeshCa(key, kid) }
|
||
|
}
|
||
|
// Resolve THIS device's real relationship to the mesh from the server,
|
||
|
// rather than assuming. The mesh OWNER (its creator) is the GATEWAY: it
|
||
|
// runs the single leader uplink and holds GATEWAY/ROOT/ANCHOR. Every
|
||
| androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt | ||
|---|---|---|
|
package com.aether.mofe.platform.netcode
|
||
|
import android.util.Base64
|
||
|
import android.util.Log
|
||
|
import com.aether.mofe.messaging.MeshCodec
|
||
|
import com.aether.mofe.messaging.security.DeviceIdentity
|
||
|
import com.aether.mofe.platform.identity.DeviceIdentityProvider
|
||
|
import com.aether.mofe.platform.identity.RawSeedProvider
|
||
|
/**
|
||
|
* Assembles the two trust inputs the netcode assembly (`MeshNode.build` → `MeshNodeDependencies`)
|
||
|
* needs, from material this app already holds — the on-device realization of the CA trust-root
|
||
|
* consolidation:
|
||
|
*
|
||
|
* • [meshCaPublicKey] — the Ed25519 mesh-CA trust anchor, FETCHED and PINNED from the server's
|
||
|
* `GET /api/v1/mesh/ca` over the web-PKI-authenticated TLS channel (see
|
||
|
* `ControlPlaneApi.fetchMeshCaPublicKey` and the trust-root assessment). Pinning it once means
|
||
|
* every peer credential is verified against ONE central CA rather than a per-build embedded key.
|
||
|
* • [deviceIdentity] — this device's Ed25519 keypair (from its seed) bound to its mesh-CA-signed
|
||
|
* credential, parsed from the server's JSON into the unified `DeviceCredential`
|
||
|
* (`MeshCodec.decodeCredentialJson`). The same credential now authenticates the device on both
|
||
|
* the uplink and the peer-to-peer netcode planes.
|
||
|
*
|
||
|
* This is the seam `MeshNodeDependencies(meshCaPublicKey = …, identity = …)` reads from. NOTE:
|
||
|
* constructing and starting the actual `MeshNode` is the remaining netcode go-live step and is
|
||
|
* still gated on peer IP discovery (the band carries UWB addresses, not IPs); this class only makes
|
||
|
* the trust material ready so that step is a straight wire-up.
|
||
|
*/
|
||
|
object NetcodeTrust {
|
||
|
private const val TAG = "NetcodeTrust"
|
||
|
@Volatile private var pinnedCaPublicKey: ByteArray? = null
|
||
|
@Volatile private var pinnedKeyId: String? = 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.
|
||
|
*/
|
||
|
fun pinMeshCa(meshCaPublicKeyB64: String, keyId: String?) {
|
||
|
val decoded = runCatching { Base64.decode(meshCaPublicKeyB64, Base64.DEFAULT) }.getOrNull() ?: return
|
||
|
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
|
||
|
}
|
||
|
/** The pinned Ed25519 mesh CA public key (raw bytes), or null until [pinMeshCa] has run. */
|
||
|
fun meshCaPublicKey(): ByteArray? = pinnedCaPublicKey
|
||
|
/** The pinned CA key id (for diagnostics / rotation detection), or null. */
|
||
|
fun keyId(): String? = pinnedKeyId
|
||
|
/**
|
||
|
* Build the netcode [DeviceIdentity] for `MeshNodeDependencies` from this device's seed and the
|
||
|
* mesh-CA credential JSON pulled from the server (`ControlPlaneApi.pullCredential`, stringified).
|
||
|
* The 32-byte Ed25519 seed IS the private key. Returns null if the seed is unavailable (a
|
||
|
* Keystore-backed identity that can't export it) or the credential can't be parsed.
|
||
|
*/
|
||
|
fun deviceIdentity(self: DeviceIdentityProvider, credentialJson: String): DeviceIdentity? {
|
||
|
val seedB64 = (self as? RawSeedProvider)?.seedB64 ?: return null
|
||
|
val cred = MeshCodec.decodeCredentialJson(credentialJson) ?: return null
|
||
|
val priv = runCatching { Base64.decode(seedB64, Base64.DEFAULT) }.getOrNull() ?: return null
|
||
|
val pub = runCatching { Base64.decode(self.ed25519PubB64, Base64.DEFAULT) }.getOrNull() ?: return null
|
||
|
return DeviceIdentity(self.deviceIdV5(), pub, priv, cred)
|
||
|
}
|
||
|
}
|
||
| common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt | ||
|---|---|---|
|
* hex). This is a CROSS-REPO LOCK-STEP contract with `gateway/ca.py::_canonical` — see
|
||
|
* [DeviceCredential]. `notAfter` is an integer (microseconds); `is_emulated` a bare bool.
|
||
|
*/
|
||
|
/**
|
||
|
* Parse a mesh-CA [DeviceCredential] from the tome-server's JSON — the `credential` object
|
||
|
* returned by enroll / pullCredential. Field names match the server exactly (see
|
||
|
* [DeviceCredential], incl. `is_emulated`), so kotlinx JSON deserializes it directly; the parsed
|
||
|
* credential re-encodes via [encodeCredentialBody] to the SAME canonical bytes the CA signed, so
|
||
|
* it verifies against the mesh CA public key unchanged. Returns null on malformed input. This is
|
||
|
* the on-device bridge from the JSON control plane to the netcode credential type.
|
||
|
*/
|
||
|
fun decodeCredentialJson(jsonText: String): DeviceCredential? = try {
|
||
|
json.decodeFromString(DeviceCredential.serializer(), jsonText)
|
||
|
} catch (_: Exception) { null }
|
||
|
fun encodeCredentialBody(c: DeviceCredential): ByteArray {
|
||
|
val sb = StringBuilder(256)
|
||
|
sb.append('{')
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt | ||
|---|---|---|
|
"\"notAfter\":1893456000000000}"
|
||
|
assertEquals(expected, MeshCodec.encodeCredentialBody(cred).decodeToString())
|
||
|
}
|
||
|
@Test
|
||
|
fun decodeCredentialJsonParsesServerCredentialAndReencodesToCanonicalBody() {
|
||
|
// A credential object shaped exactly like the tome-server enroll/pullCredential response
|
||
|
// (arbitrary key order, whitespace). Parsing it and re-encoding the body MUST reproduce the
|
||
|
// CA's signed canonical bytes, so the server signature verifies unchanged on-device.
|
||
|
val serverJson =
|
||
|
"""{"deviceId": "11111111-2222-3333-4444-555555555555", "ed25519Pub":""" +
|
||
|
""" "TWVzaFB1YktleTAxMjM0NTY3ODlBQkNERUZHSA==", "deviceType": "ANCHOR",""" +
|
||
|
""" "deviceName": "Aether 1", "deviceRoles": ["GATEWAY", "ROOT", "ANCHOR"],""" +
|
||
|
""" "meshId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "meshName": "Warehouse A",""" +
|
||
|
""" "notAfter": 1900000000000000, "claims": ["voterEligible", "meshCreate"],""" +
|
||
|
""" "is_emulated": false, "signature": "c2lnbmF0dXJlLXBsYWNlaG9sZGVy"}"""
|
||
|
val cred = MeshCodec.decodeCredentialJson(serverJson)!!
|
||
|
assertEquals("11111111-2222-3333-4444-555555555555", cred.deviceId)
|
||
|
assertEquals(1900000000000000L, cred.notAfterMicros)
|
||
|
assertTrue(cred.voterEligible) // derived from the claims list
|
||
|
assertTrue("meshCreate" in cred.claims)
|
||
|
assertEquals(false, cred.isEmulated)
|
||
|
val expectedCanonical =
|
||
|
"{\"claims\":[\"voterEligible\",\"meshCreate\"]," +
|
||
|
"\"deviceId\":\"11111111-2222-3333-4444-555555555555\",\"deviceName\":\"Aether 1\"," +
|
||
|
"\"deviceRoles\":[\"GATEWAY\",\"ROOT\",\"ANCHOR\"],\"deviceType\":\"ANCHOR\"," +
|
||
|
"\"ed25519Pub\":\"TWVzaFB1YktleTAxMjM0NTY3ODlBQkNERUZHSA==\",\"is_emulated\":false," +
|
||
|
"\"meshId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"meshName\":\"Warehouse A\"," +
|
||
|
"\"notAfter\":1900000000000000}"
|
||
|
assertEquals(expectedCanonical, MeshCodec.encodeCredentialBody(cred).decodeToString())
|
||
|
}
|
||
|
}
|
||