// SPDX-License-Identifier: LGPL-3.0-or-later // Part of MeshBay's protocol layer, under the LGPL so that any client may use it. package org.meshbay.client.keys import org.json.JSONObject import java.io.ByteArrayOutputStream import java.util.Base64 import kotlin.math.abs /** * What a node identity signs, built here from named fields — never bytes the * page chose. A port of meshbay-client/src/transcripts.js, byte for byte; the * shapes it checks and the refusals it gives are the same, and * meshbay-hub/tests/vectors/keyring.json is what holds them (and * meshbay_common) together. * * `now` is injected so the vectors can pin the clock; production passes the * system clock. */ class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / 1000.0 }) { class Refused(what: String) : IllegalArgumentException("Refused: $what") data class Ctx(val userId: String, val nodePk: String, val pkEdB64: String, val pkXB64: String) private fun refuse(what: String): Nothing = throw Refused(what) private fun enc(s: String) = s.toByteArray(Charsets.UTF_8) private fun lenPrefixed(prefix: String, parts: List): ByteArray { val out = ByteArrayOutputStream() out.write(prefix.toByteArray(Charsets.UTF_8)) for (p in parts) { out.write(byteArrayOf((p.size ushr 24).toByte(), (p.size ushr 16).toByte(), (p.size ushr 8).toByte(), p.size.toByte())) out.write(p) } return out.toByteArray() } // JavaScript's String(v ?? '') over a value that came through JSON. private fun jsString(v: Any?): String = when (v) { null, JSONObject.NULL -> "" is String -> v is Boolean -> v.toString() is Int, is Long -> v.toString() is Number -> { val d = v.toDouble() if (d == Math.floor(d) && !d.isInfinite() && abs(d) < 1e21) d.toLong().toString() else d.toString() } else -> v.toString() } // JavaScript's Number(v), for the values a timestamp or an epoch can arrive as. private fun jsNumber(v: Any?): Double = when (v) { null, JSONObject.NULL -> if (v == null) Double.NaN else 0.0 is Number -> v.toDouble() is Boolean -> if (v) 1.0 else 0.0 is String -> v.trim().let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() ?: Double.NaN } else -> Double.NaN } private fun isInteger(d: Double) = !d.isNaN() && !d.isInfinite() && d == Math.floor(d) private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$") private fun bytes(v: Any?, what: String, min: Int = 1, max: Int = 64): ByteArray { val s = jsString(v) if (!base64Shape.matches(s)) refuse("$what is not base64") // Node's decoder is lenient where Java's throws (a lone trailing // character). Both outcomes are a refusal: Node's yields a short // buffer that the length check below refuses. val b = try { Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) { refuse("$what has the wrong length") } if (b.size < min || b.size > max) refuse("$what has the wrong length") return b } private fun key32(v: Any?, what: String): String { bytes(v, what, 32, 32) return jsString(v) } private fun text(v: Any?, what: String, max: Int = 256): String { val s = jsString(v) if (s.length > max) refuse("$what is too long") // UTF-16 units, as JS counts return s } private val groupShape = Regex("^[A-Za-z0-9_-]{1,64}$") private fun groupId(v: Any?): String { val s = jsString(v) if (s.isNotEmpty() && !groupShape.matches(s)) refuse("not a group id") return s } private fun timestamp(v: Any?): String { val n = jsNumber(v) if (!isInteger(n) || abs(n - now()) > TS_SLACK_S) refuse("the timestamp is not now") return jsString(n.toLong()) } fun forKind(kind: String, f: JSONObject?, ctx: Ctx): ByteArray { val fields = f ?: JSONObject() fun field(name: String): Any? = if (fields.has(name)) fields.get(name) else null fun sameNode(): String { if (jsString(field("nodePk")) != ctx.nodePk) refuse("another node") return ctx.nodePk } fun sameUser(): String { if (jsString(field("userId")) != ctx.userId) refuse("another account") return ctx.userId } fun nonceNode() = bytes(field("nonceNode"), "the node nonce", 16, 64) return when (kind) { "join" -> lenPrefixed(PREFIX_JOIN, listOf( enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(field("ts"))))) "device_hello" -> lenPrefixed(PREFIX_DEVICE_HELLO, listOf( enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), enc(ctx.pkEdB64), nonceNode(), enc(timestamp(field("ts"))))) "device_request" -> { val codeHash = jsString(field("codeHash")) if (!Regex("^[0-9a-f]{64}$").matches(codeHash)) refuse("not a request hash") lenPrefixed(PREFIX_DEVICE_REQUEST, listOf( enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), enc(codeHash), nonceNode(), enc(timestamp(field("ts"))))) } "device_add" -> lenPrefixed(PREFIX_DEVICE_ADD, listOf( enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), enc(key32(field("pkX"), "the device key")), nonceNode(), enc(timestamp(field("ts"))))) "device_revoke" -> lenPrefixed(PREFIX_DEVICE_REVOKE, listOf( enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), nonceNode(), enc(timestamp(field("ts"))))) "chat" -> { val epoch = jsNumber(field("epoch")) if (!isInteger(epoch) || epoch < 0) refuse("not an epoch") lenPrefixed(PREFIX_CHAT, listOf( enc(groupId(field("groupId"))), enc(jsString(epoch.toLong())), Base64.getDecoder().decode(ctx.pkEdB64), bytes(field("nonce"), "the message nonce", 12, 24), bytes(field("ct"), "the message", 1, 8 * 1024 * 1024))) } "admin" -> { val op = jsString(field("op")) if (op !in ADMIN_OPS) refuse("not an operation") lenPrefixed(PREFIX_ADMIN, listOf( enc(op), enc(sameNode()), enc(groupId(field("groupId"))), enc(text(field("subject"), "the subject", 16384)), bytes(field("nonce"), "the challenge nonce", 16, 64), enc(timestamp(field("ts"))))) } else -> refuse("nothing is signed as \"${kind.take(32)}\"") } } companion object { // The node's clock and ours: a signature for a moment far from now is one to keep for later. const val TS_SLACK_S = 600 // The signed operations this application asks a node to perform // (meshbay_common/adminop.py) — transcripts.js's list, held equal by // test_android_keys.py. A list, not a pattern: what widens a node's // sharing (root_add, root_update, group_attach — gone from MNP 6.0) is // never signed here, so a script in the page cannot drive an older node // into it either. val ADMIN_OPS = setOf( "file_delete", "dir_delete", "invite_create", "invite_link_create", "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings", "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch", "musicbrainz_enabled", "root_remove", "root_eject", "root_plug", "app_directories", "chat_directory", "chat_link_preview", "search_listed", "chat_epoch", ) const val PREFIX_JOIN = "meshbay:join:v1" const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1" const val PREFIX_DEVICE_ADD = "meshbay:device_add:v1" const val PREFIX_DEVICE_REVOKE = "meshbay:device_revoke:v1" const val PREFIX_DEVICE_HELLO = "meshbay:device_hello:v1" const val PREFIX_CHAT = "meshbay:chat:v1" const val PREFIX_ADMIN = "meshbay:admin:v1" } }