From 6688b551577d535298fd6667b41ddf52c5a83651 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:52:13 +0000 Subject: [PATCH] feat(netcode): fetch+pin the mesh CA and assemble the netcode trust material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the CA trust anchor and the unified credential into the inputs the netcode assembly (MeshNode.build → MeshNodeDependencies) needs, completing the on-device half of the trust-root consolidation. common (verified): * MeshCodec.decodeCredentialJson parses the tome-server credential JSON (the `credential` object from enroll/pullCredential) into the unified DeviceCredential. Golden test: parse a server-shaped credential, then re-encode the body → byte-identical to the CA's canonical bytes, so the server signature verifies unchanged after the JSON round-trip. androidApp (not compilable in this env — wiring only): * ControlPlaneApi.fetchMeshCaPublicKey — GET /api/v1/mesh/ca over the web-PKI- authenticated TLS channel, returning the base64 Ed25519 key + keyId. * NetcodeTrust — pins the fetched CA key and builds the netcode DeviceIdentity from this device's Ed25519 seed + its parsed mesh-CA credential; exposes both as the seam MeshNodeDependencies(meshCaPublicKey=…, identity=…) reads from. Logs a changed CA keyId (rotation is a trust event). * MeshRegistrar.connectTo pins the CA key during enrollment (best-effort; a missing endpoint / offline server never blocks the band-native mesh). REMAINING go-live gate: constructing and starting the actual MeshNode is still blocked on peer IP discovery (the band carries UWB addresses, not IPs). This change makes the trust material ready so that step is a straight wire-up. Verified: full :common:jvmTest green (675 passed, 0 failed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/platform/control/ControlPlaneAPi.kt | 16 +++++ .../mofe/platform/control/MeshRegistrar.kt | 10 +++ .../mofe/platform/netcode/NetcodeTrust.kt | 68 +++++++++++++++++++ .../com/aether/mofe/messaging/MeshCodec.kt | 12 ++++ .../aether/mofe/messaging/MeshCodecTest.kt | 28 ++++++++ 5 files changed, 134 insertions(+) create mode 100644 androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt diff --git a/androidApp/src/main/java/com/aether/mofe/platform/control/ControlPlaneAPi.kt b/androidApp/src/main/java/com/aether/mofe/platform/control/ControlPlaneAPi.kt index 8269c35..36cec64 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/control/ControlPlaneAPi.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/control/ControlPlaneAPi.kt @@ -21,8 +21,10 @@ import kotlinx.serialization.json.JsonObject 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). */ @@ -80,6 +82,20 @@ class ControlPlaneApi(private val baseUrl: String, private val auth: AuthClient) 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? { + 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) diff --git a/androidApp/src/main/java/com/aether/mofe/platform/control/MeshRegistrar.kt b/androidApp/src/main/java/com/aether/mofe/platform/control/MeshRegistrar.kt index bf28522..48dd951 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/control/MeshRegistrar.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/control/MeshRegistrar.kt @@ -13,6 +13,7 @@ import com.aether.mofe.platform.band.BandMeshMeta 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, @@ -47,6 +48,15 @@ class MeshRegistrar( 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 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 new file mode 100644 index 0000000..8ca7ab1 --- /dev/null +++ b/androidApp/src/main/java/com/aether/mofe/platform/netcode/NetcodeTrust.kt @@ -0,0 +1,68 @@ +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) + } +} diff --git a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt index 553d74f..b172f50 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt @@ -59,6 +59,18 @@ object MeshCodec { * 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('{') diff --git a/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt index a655c74..3c6250f 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt @@ -127,4 +127,32 @@ class MeshCodecTest { "\"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()) + } } \ No newline at end of file -- 2.43.0