From 5ed3f2471782a1f882fea4f47e43dd4ba73b47a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:46:30 +0000 Subject: [PATCH] feat(netcode): unify DeviceCredential with the server's canonical-JSON mesh CA credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P2P netcode LinkHandshake verified a CBOR-encoded credential with a field set that did not match the tome-server mesh-enrollment CA, which mints an Ed25519-signed canonical-JSON credential. So a real server-issued credential could never authenticate a device-to-device link — the two trust planes spoke different formats. Align the client to the server's already-live, already-tested credential so ONE credential authenticates a device on both planes: * DeviceCredential now mirrors gateway/ca.py::MeshCA.mint field-for-field (deviceId, ed25519Pub, deviceType, deviceName, deviceRoles, meshId, meshName, notAfter µs, claims, is_emulated, signature). ed25519Pub/signature are base64 strings, exactly as in the CA's JSON, so the signed body is reproducible verbatim. voterEligible/notAfterMicros are kept as convenience accessors (voterEligible is a claim; notAfter is the expiry). * MeshCodec.encodeCredentialBody now emits the EXACT bytes the CA signs — Python json.dumps(body, sort_keys=True, separators=(",",":"), ensure_ascii= True) with the signature excluded — reproduced byte-for-byte (sorted keys, Python-identical string escaping incl. lower-hex \uXXXX for control/non-ASCII and surrogate pairs for astral chars, integer notAfter, bare bool). * MeshBase64: dependency-free standard base64 (matches Python base64.b64encode) to convert the credential's base64 key/signature to bytes for the crypto provider; LinkHandshake decodes ed25519Pub/signature through it. This is a CROSS-REPO LOCK-STEP contract with gateway/ca.py: the field set, the sorted-key canonical JSON, and the base64 encoding must stay identical or signatures stop verifying. The server minting path is unchanged. Verified: new golden-vector test asserts encodeCredentialBody is byte-identical to Python's json.dumps output (captured from the server's _canonical) for a credential exercising an embedded quote, a non-ASCII char, and an astral emoji; LinkHandshake round-trip (sign-over-canonical-JSON → verify) intact. Full :common:jvmTest green (674 passed, 0 failed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../com/aether/mofe/messaging/MeshBase64.kt | 52 ++++++ .../com/aether/mofe/messaging/MeshCodec.kt | 165 ++++++++++++------ .../mofe/messaging/security/LinkHandshake.kt | 9 +- .../mofe/model/messaging/SecurityMessages.kt | 60 +++---- .../aether/mofe/messaging/MeshCodecTest.kt | 44 ++++- .../mofe/messaging/support/TestFactories.kt | 9 +- 6 files changed, 243 insertions(+), 96 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshBase64.kt diff --git a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshBase64.kt b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshBase64.kt new file mode 100644 index 0000000..e216883 --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshBase64.kt @@ -0,0 +1,52 @@ +package com.aether.mofe.messaging + +/** + * Standard Base64 (RFC 4648, `+/` alphabet, `=` padding, no line breaks) — the exact form Python's + * `base64.b64encode` produces on the server. The mesh CA credential carries `ed25519Pub` and + * `signature` as base64 STRINGS (so the client can reproduce the CA's canonical-JSON signed body + * byte-for-byte); this codec converts those to/from raw key/signature bytes for the crypto provider. + * Kept dependency-free (no experimental stdlib Base64, no platform API) so it is identical on every + * Kotlin target. + */ +object MeshBase64 { + private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + private val DECODE = IntArray(128) { -1 }.also { for (i in ALPHABET.indices) it[ALPHABET[i].code] = i } + + fun encode(data: ByteArray): String { + if (data.isEmpty()) return "" + val sb = StringBuilder((data.size + 2) / 3 * 4) + var i = 0 + while (i + 3 <= data.size) { + val n = (data[i].toInt() and 0xff shl 16) or (data[i + 1].toInt() and 0xff shl 8) or (data[i + 2].toInt() and 0xff) + sb.append(ALPHABET[n ushr 18 and 0x3f]); sb.append(ALPHABET[n ushr 12 and 0x3f]) + sb.append(ALPHABET[n ushr 6 and 0x3f]); sb.append(ALPHABET[n and 0x3f]) + i += 3 + } + when (data.size - i) { + 1 -> { + val n = data[i].toInt() and 0xff shl 16 + sb.append(ALPHABET[n ushr 18 and 0x3f]); sb.append(ALPHABET[n ushr 12 and 0x3f]); sb.append("==") + } + 2 -> { + val n = (data[i].toInt() and 0xff shl 16) or (data[i + 1].toInt() and 0xff shl 8) + sb.append(ALPHABET[n ushr 18 and 0x3f]); sb.append(ALPHABET[n ushr 12 and 0x3f]) + sb.append(ALPHABET[n ushr 6 and 0x3f]); sb.append('=') + } + } + return sb.toString() + } + + /** Decode standard base64. Ignores `=` padding; throws on an invalid character. */ + fun decode(s: String): ByteArray { + val clean = s.trimEnd('=') + val out = ByteArray(clean.length * 6 / 8) + var buffer = 0; var bits = 0; var oi = 0 + for (c in clean) { + val v = if (c.code < 128) DECODE[c.code] else -1 + require(v >= 0) { "invalid base64 character: '$c'" } + buffer = (buffer shl 6) or v; bits += 6 + if (bits >= 8) { bits -= 8; out[oi++] = (buffer ushr bits and 0xff).toByte() } + } + return if (oi == out.size) out else out.copyOf(oi) + } +} 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 ff83d4c..553d74f 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCodec.kt @@ -1,57 +1,110 @@ -package com.aether.mofe.messaging - -import com.aether.mofe.model.messaging.DeviceCredential -import com.aether.mofe.model.messaging.MeshPayload -import com.aether.mofe.model.messaging.MessageEnvelope -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.json.Json - -/** - * Single place that owns the wire format. CBOR on both mesh planes, - * JSON for the uplink and for debug logging. - * - * `ignoreUnknownKeys = true` is the schema-evolution contract: a v1 node - * can receive a v2 message that added fields and still decode it. - */ -@OptIn(ExperimentalSerializationApi::class) -object MeshCodec { - - val cbor: Cbor = Cbor { ignoreUnknownKeys = true } - - val json: Json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - coerceInputValues = true // unknown enum → property default (e.g. CommandKind.UNKNOWN) - } - - fun encode(envelope: MessageEnvelope): ByteArray = - cbor.encodeToByteArray(MessageEnvelope.serializer(), envelope) - - /** - * Returns null instead of throwing on any decode failure — a malformed - * or future-typed datagram must never take down a collector loop. - * Callers count failures via [MessagingMetrics]. - */ - fun decodeOrNull(bytes: ByteArray): MessageEnvelope? = try { - cbor.decodeFromByteArray(MessageEnvelope.serializer(), bytes) - } catch (_: Exception) { - null - } - - fun encodeJson(envelope: MessageEnvelope): String = - json.encodeToString(MessageEnvelope.serializer(), envelope) - - /** Handshake frames are bare MeshPayloads (no envelope — pre-session). */ - fun encodeHandshake(payload: MeshPayload): ByteArray = - cbor.encodeToByteArray(MeshPayload.serializer(), payload) - - inline fun decodeHandshake(bytes: ByteArray): T? = try { - cbor.decodeFromByteArray(MeshPayload.serializer(), bytes) as? T - } catch (_: Exception) { null } - - /** Canonical CBOR of a credential with its signature field zeroed — - * the exact bytes the mesh CA signs and verifiers check (§6.3). */ - fun encodeCredentialBody(c: DeviceCredential): ByteArray = - cbor.encodeToByteArray(DeviceCredential.serializer(), c.copy(signature = ByteArray(0))) +package com.aether.mofe.messaging + +import com.aether.mofe.model.messaging.DeviceCredential +import com.aether.mofe.model.messaging.MeshPayload +import com.aether.mofe.model.messaging.MessageEnvelope +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.json.Json + +/** + * Single place that owns the wire format. CBOR on both mesh planes, + * JSON for the uplink and for debug logging. + * + * `ignoreUnknownKeys = true` is the schema-evolution contract: a v1 node + * can receive a v2 message that added fields and still decode it. + */ +@OptIn(ExperimentalSerializationApi::class) +object MeshCodec { + + val cbor: Cbor = Cbor { ignoreUnknownKeys = true } + + val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + coerceInputValues = true // unknown enum → property default (e.g. CommandKind.UNKNOWN) + } + + fun encode(envelope: MessageEnvelope): ByteArray = + cbor.encodeToByteArray(MessageEnvelope.serializer(), envelope) + + /** + * Returns null instead of throwing on any decode failure — a malformed + * or future-typed datagram must never take down a collector loop. + * Callers count failures via [MessagingMetrics]. + */ + fun decodeOrNull(bytes: ByteArray): MessageEnvelope? = try { + cbor.decodeFromByteArray(MessageEnvelope.serializer(), bytes) + } catch (_: Exception) { + null + } + + fun encodeJson(envelope: MessageEnvelope): String = + json.encodeToString(MessageEnvelope.serializer(), envelope) + + /** Handshake frames are bare MeshPayloads (no envelope — pre-session). */ + fun encodeHandshake(payload: MeshPayload): ByteArray = + cbor.encodeToByteArray(MeshPayload.serializer(), payload) + + inline fun decodeHandshake(bytes: ByteArray): T? = try { + cbor.decodeFromByteArray(MeshPayload.serializer(), bytes) as? T + } catch (_: Exception) { null } + + /** + * The EXACT bytes the mesh CA signs and verifiers check: the credential body (every field + * except `signature`) as canonical JSON — Python `json.dumps(body, sort_keys=True, + * separators=(",",":"), ensure_ascii=True)` — reproduced byte-for-byte so a tome-server-minted + * credential verifies on-device. Keys are emitted in sorted order; strings are escaped exactly + * like Python's `ensure_ascii` (control chars and every non-ASCII code unit → `\uXXXX`, lower + * 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. + */ + fun encodeCredentialBody(c: DeviceCredential): ByteArray { + val sb = StringBuilder(256) + sb.append('{') + sb.append("\"claims\":"); jsonArray(sb, c.claims); sb.append(',') + sb.append("\"deviceId\":"); jsonStr(sb, c.deviceId); sb.append(',') + sb.append("\"deviceName\":"); jsonStr(sb, c.deviceName); sb.append(',') + sb.append("\"deviceRoles\":"); jsonArray(sb, c.deviceRoles); sb.append(',') + sb.append("\"deviceType\":"); jsonStr(sb, c.deviceType); sb.append(',') + sb.append("\"ed25519Pub\":"); jsonStr(sb, c.ed25519Pub); sb.append(',') + sb.append("\"is_emulated\":").append(if (c.isEmulated) "true" else "false").append(',') + sb.append("\"meshId\":"); jsonStr(sb, c.meshId); sb.append(',') + sb.append("\"meshName\":"); jsonStr(sb, c.meshName); sb.append(',') + sb.append("\"notAfter\":").append(c.notAfter.toString()) + sb.append('}') + return sb.toString().encodeToByteArray() + } + + private fun jsonArray(sb: StringBuilder, items: List) { + sb.append('[') + for (i in items.indices) { if (i > 0) sb.append(','); jsonStr(sb, items[i]) } + sb.append(']') + } + + /** Append [s] as a JSON string escaped exactly like Python `json.dumps(ensure_ascii=True)`: + * the short escapes for the control chars that have them, `\uXXXX` (lower-hex) for every other + * code unit below 0x20 or above 0x7E, and `\"` / `\\` for quote and backslash. */ + private fun jsonStr(sb: StringBuilder, s: String) { + sb.append('"') + for (c in s) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> + if (c.code < 0x20 || c.code > 0x7E) { + sb.append("\\u") + val hex = c.code.toString(16) + repeat(4 - hex.length) { sb.append('0') } + sb.append(hex) + } else sb.append(c) + } + } + sb.append('"') + } } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/aether/mofe/messaging/security/LinkHandshake.kt b/common/src/commonMain/kotlin/com/aether/mofe/messaging/security/LinkHandshake.kt index eb0d1cf..5682a18 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/messaging/security/LinkHandshake.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/messaging/security/LinkHandshake.kt @@ -1,6 +1,7 @@ package com.aether.mofe.messaging.security import com.aether.mofe.messaging.ControlLink +import com.aether.mofe.messaging.MeshBase64 import com.aether.mofe.messaging.MeshCodec import com.aether.mofe.model.messaging.DeviceCredential import com.aether.mofe.model.messaging.SecHelloAck @@ -54,7 +55,7 @@ class LinkHandshake( val transcript1 = crypto.sha256( MeshCodec.encodeHandshake(init) + MeshCodec.encodeHandshake(ack.copy(signature = ByteArray(0))), ) - if (!crypto.ed25519Verify(ack.credential.ed25519Public, transcript1, ack.signature)) + if (!crypto.ed25519Verify(MeshBase64.decode(ack.credential.ed25519Pub), transcript1, ack.signature)) throw HandshakeException("leader transcript signature invalid") val transcript2 = crypto.sha256(transcript1 + ack.signature) @@ -89,7 +90,7 @@ class LinkHandshake( val done = MeshCodec.decodeHandshake(link.inboundFrames().first()) ?: throw HandshakeException("expected helloDone") val transcript2 = crypto.sha256(transcript1 + ack.signature) - if (!crypto.ed25519Verify(init.credential.ed25519Public, transcript2, done.signature)) + if (!crypto.ed25519Verify(MeshBase64.decode(init.credential.ed25519Pub), transcript2, done.signature)) throw HandshakeException("member transcript signature invalid") return deriveKeys( @@ -100,8 +101,8 @@ class LinkHandshake( } private fun verifyCredential(c: DeviceCredential) { - val body = MeshCodec.encodeCredentialBody(c) // canonical CBOR, signature excluded - if (!crypto.ed25519Verify(meshCaPublicKey, body, c.signature)) + val body = MeshCodec.encodeCredentialBody(c) // canonical JSON, signature excluded + if (!crypto.ed25519Verify(meshCaPublicKey, body, MeshBase64.decode(c.signature))) throw HandshakeException("credential not signed by mesh CA") // notAfterMicros checked against local clock by the caller (clock skew policy) } diff --git a/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/SecurityMessages.kt b/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/SecurityMessages.kt index afbcde2..4e06456 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/SecurityMessages.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/SecurityMessages.kt @@ -5,45 +5,45 @@ import kotlinx.serialization.Serializable /** * Mesh-CA-signed binding of a device identity to the mesh (Design §4.1). - * [signature] is Ed25519(meshCaPrivate, canonical CBOR of all other fields — - * see MeshCodec.encodeCredentialBody). + * + * This mirrors the tome-server mesh-enrollment CA credential FIELD-FOR-FIELD so ONE credential + * authenticates a device on both trust planes (the device↔server uplink and the device↔device + * netcode links). [signature] is Ed25519(meshCaPrivate, canonical JSON of all other fields) — the + * EXACT bytes the server signs: Python `json.dumps(body, sort_keys=True, separators=(",",":"), + * ensure_ascii=True)` with the signature excluded, reproduced byte-for-byte by + * [com.aether.mofe.messaging.MeshCodec.encodeCredentialBody]. [ed25519Pub] and [signature] are + * base64 strings (as in the CA's JSON) so that canonical body is reproducible verbatim; the security + * layer base64-decodes them for the crypto provider. + * + * CROSS-REPO LOCK-STEP: this field set, the sorted-key canonical JSON, and the base64 encoding must + * stay identical to tome-server `gateway/ca.py::MeshCA.mint`. Adding/removing a field here requires + * the same change there (and vice-versa), or signatures stop verifying. */ @Serializable data class DeviceCredential( val deviceId: String, - val ed25519Public: ByteArray, + /** base64 raw Ed25519 public key (server field `ed25519Pub`). */ + val ed25519Pub: String, val deviceType: String, + val deviceName: String, + val deviceRoles: List, val meshId: String, - val notAfterMicros: Long, - val voterEligible: Boolean, - val signature: ByteArray, + val meshName: String, + /** expiry, microseconds since epoch (server field `notAfter`). */ + val notAfter: Long, + val claims: List, + @SerialName("is_emulated") val isEmulated: Boolean, + /** base64 Ed25519 signature over the canonical body (server field `signature`). */ + val signature: String, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DeviceCredential - - if (notAfterMicros != other.notAfterMicros) return false - if (voterEligible != other.voterEligible) return false - if (deviceId != other.deviceId) return false - if (!ed25519Public.contentEquals(other.ed25519Public)) return false - if (deviceType != other.deviceType) return false - if (meshId != other.meshId) return false - if (!signature.contentEquals(other.signature)) return false + /** Expiry as engine-clock micros — the CA field is named [notAfter]. */ + val notAfterMicros: Long get() = notAfter - return true - } + /** Voter eligibility is carried by the CA as a claim, not a boolean field. */ + val voterEligible: Boolean get() = CLAIM_VOTER_ELIGIBLE in claims - override fun hashCode(): Int { - var result = notAfterMicros.hashCode() - result = 31 * result + voterEligible.hashCode() - result = 31 * result + deviceId.hashCode() - result = 31 * result + ed25519Public.contentHashCode() - result = 31 * result + deviceType.hashCode() - result = 31 * result + meshId.hashCode() - result = 31 * result + signature.contentHashCode() - return result + companion object { + const val CLAIM_VOTER_ELIGIBLE = "voterEligible" } } 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 89b8ed9..a655c74 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt @@ -71,7 +71,11 @@ class MeshCodecTest { @Test fun handshakeHelpersRoundTripAndRejectWrongType() { val init = SecHelloInit( - credential = DeviceCredential("d", byteArrayOf(1), "ANCHOR", "m", 9, true, byteArrayOf(2)), + credential = DeviceCredential( + deviceId = "d", ed25519Pub = "AQ==", deviceType = "ANCHOR", deviceName = "dev", + deviceRoles = listOf("ANCHOR"), meshId = "m", meshName = "mesh", notAfter = 9, + claims = listOf("voterEligible"), isEmulated = false, signature = "Ag==", + ), ephemeralX25519Public = byteArrayOf(3), nonce = byteArrayOf(4), ) val bytes = MeshCodec.encodeHandshake(init) @@ -82,11 +86,45 @@ class MeshCodecTest { @Test fun credentialBodyExcludesSignature() { - val base = DeviceCredential("d", byteArrayOf(1), "ANCHOR", "m", 9, true, byteArrayOf(9, 9)) - val other = base.copy(signature = byteArrayOf(7, 7, 7)) + val base = DeviceCredential( + deviceId = "d", ed25519Pub = "AQ==", deviceType = "ANCHOR", deviceName = "dev", + deviceRoles = listOf("ANCHOR"), meshId = "m", meshName = "mesh", notAfter = 9, + claims = listOf("voterEligible"), isEmulated = false, signature = "CQk=", + ) + val other = base.copy(signature = "Bwc=") assertTrue( MeshCodec.encodeCredentialBody(base).contentEquals(MeshCodec.encodeCredentialBody(other)), "signed body must be identical regardless of the signature field", ) } + + @Test + fun credentialBodyMatchesServerCanonicalJsonByteForByte() { + // GROUND TRUTH captured verbatim from tome-server gateway/ca.py::_canonical, i.e. + // Python json.dumps(body_without_signature, sort_keys=True, separators=(",",":")) — the + // exact bytes the mesh CA signs. Exercises the tricky cases: an embedded double quote (\"), + // a non-ASCII BMP char (U+00E9 -> é, lower hex), and an astral char (U+1F697 -> the + // UTF-16 surrogate pair 🚗). Any drift in either repo's canonicalization fails + // this test — signatures would silently stop verifying cross-repo. + val cred = DeviceCredential( + deviceId = "dev-123", + ed25519Pub = "AAECAwQFBgcICQoLDA0ODw==", + deviceType = "ANCHOR", + deviceName = "Bob \"B\" café", // embedded quote + é + deviceRoles = listOf("ANCHOR", "ROOT"), + meshId = "mesh-abc", + meshName = "Garage 🚗", // astral emoji (surrogate pair) + notAfter = 1893456000000000L, + claims = listOf("voterEligible", "meshCreate"), + isEmulated = false, + signature = "this-field-is-excluded-from-the-body", + ) + val expected = + "{\"claims\":[\"voterEligible\",\"meshCreate\"],\"deviceId\":\"dev-123\"," + + "\"deviceName\":\"Bob \\\"B\\\" caf\\u00e9\",\"deviceRoles\":[\"ANCHOR\",\"ROOT\"]," + + "\"deviceType\":\"ANCHOR\",\"ed25519Pub\":\"AAECAwQFBgcICQoLDA0ODw==\"," + + "\"is_emulated\":false,\"meshId\":\"mesh-abc\",\"meshName\":\"Garage \\ud83d\\ude97\"," + + "\"notAfter\":1893456000000000}" + assertEquals(expected, MeshCodec.encodeCredentialBody(cred).decodeToString()) + } } \ No newline at end of file diff --git a/common/src/commonTest/kotlin/com/aether/mofe/messaging/support/TestFactories.kt b/common/src/commonTest/kotlin/com/aether/mofe/messaging/support/TestFactories.kt index 7c65453..0f5f7e7 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/messaging/support/TestFactories.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/messaging/support/TestFactories.kt @@ -10,6 +10,7 @@ import com.aether.mofe.model.RegionType import com.aether.mofe.model.Timestamp import com.aether.mofe.model.Vector3D import com.aether.mofe.model.messaging.DeviceCredential +import com.aether.mofe.messaging.MeshBase64 import com.aether.mofe.messaging.MeshCodec import com.aether.mofe.platform.randomUUID @@ -45,11 +46,13 @@ object TestFactories { fun identity(crypto: MeshCryptoProvider, deviceId: String, caPriv: ByteArray, meshId: String = "m1"): DeviceIdentity { val kp = crypto.generateEd25519() val unsigned = DeviceCredential( - deviceId = deviceId, ed25519Public = kp.publicKey, deviceType = "ANCHOR", - meshId = meshId, notAfterMicros = Long.MAX_VALUE, voterEligible = true, signature = ByteArray(0), + deviceId = deviceId, ed25519Pub = MeshBase64.encode(kp.publicKey), deviceType = "ANCHOR", + deviceName = deviceId, deviceRoles = listOf("ANCHOR"), meshId = meshId, meshName = "mesh", + notAfter = Long.MAX_VALUE, claims = listOf(DeviceCredential.CLAIM_VOTER_ELIGIBLE), + isEmulated = false, signature = "", ) val body = MeshCodec.encodeCredentialBody(unsigned) - val cred = unsigned.copy(signature = crypto.ed25519Sign(caPriv, body)) + val cred = unsigned.copy(signature = MeshBase64.encode(crypto.ed25519Sign(caPriv, body))) return DeviceIdentity(deviceId, kp.publicKey, kp.privateKey, cred) } } \ No newline at end of file -- 2.43.0