DeviceManifestSupport.kt

5.2 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67const val DEVICE_MANIFEST_VERSION = 0x018private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L910interface PairedDeviceRegistry {11    fun getAll(): List<PairedDevice>12    fun get(id: String): PairedDevice?13    fun getByPubkey(pubkeyHex: String): PairedDevice? =14        getAll().firstOrNull {15            it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true16        }17    fun upsert(device: PairedDevice)18}1920fun buildSignedDeviceManifest(21    issuerDeviceId: String,22    devices: List<ManifestDeviceEntry>,23    signingPrivateKey: Ed25519PrivateKeyParameters,24    nowMs: Long = System.currentTimeMillis(),25): SignedDeviceManifest {26    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }27    val body = DeviceManifestBody(28        manifestVersion = DEVICE_MANIFEST_VERSION,29        issuerDeviceId = issuerDeviceId,30        issuedAt = nowMs,31        devices = devices.mapNotNull(::normaliseManifestEntry),32    )33    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))34    return SignedDeviceManifest(body, pubkeyToHex(signature))35}3637fun verifyDeviceManifest(38    manifest: SignedDeviceManifest,39    issuerSigningPublicKey: ByteArray,40    nowMs: Long = System.currentTimeMillis(),41    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,42): DeviceManifestBody? {43    val body = manifest.body44    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null45    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null46    if (!isHex(manifest.signatureHex, 128)) return null47    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null48    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null49    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)50    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null51    return body52}5354fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =55    canonicalise(56        mapOf(57            "devices" to body.devices.map { manifestEntryMap(it) },58            "issuedAt" to body.issuedAt,59            "issuerDeviceId" to body.issuerDeviceId,60            "manifestVersion" to body.manifestVersion,61        ),62    )6364fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {65    val id = deviceId.takeIf { it.isNotBlank() } ?: return null66    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null67    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null68    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null69    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null70    return ManifestDeviceEntry(71        deviceId = id,72        deviceName = name,73        deviceType = type,74        longTermPublicKeyHex = longTerm,75        signingPublicKeyHex = signing,76        wsPort = wsPort,77        pairedAt = pairedAt,78    )79}8081private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {82    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null83    if (entry.deviceType !in setOf("extension", "android", "ios")) return null84    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null85    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null86    return entry.copy(87        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),88        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),89    )90}9192private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {93    val normalised = normaliseManifestEntry(entry) ?: entry94    val map = linkedMapOf<String, Any>(95        "deviceId" to normalised.deviceId,96        "deviceName" to normalised.deviceName,97        "deviceType" to normalised.deviceType,98        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),99        "pairedAt" to normalised.pairedAt,100        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),101    )102    normalised.wsPort?.let { map["wsPort"] = it }103    return map104}105106private fun canonicalise(value: Any?): String = when (value) {107    null -> "null"108    is Number -> {109        val d = value.toDouble()110        require(d.isFinite()) { "non-finite number" }111        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()112    }113    is String -> Json.encodeToString(String.serializer(), value)114    is Boolean -> if (value) "true" else "false"115    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }116    is Map<*, *> -> value.entries117        .map { (k, v) -> k.toString() to v }118        .sortedBy { it.first }119        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->120            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)121        }122    else -> error("cannot canonicalise ${value::class.simpleName}")123}124125private fun isHex(value: String, length: Int): Boolean =126    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }