DeviceManifestSupport.kt

5.1 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 upsert(device: PairedDevice)14}1516fun buildSignedDeviceManifest(17    issuerDeviceId: String,18    devices: List<ManifestDeviceEntry>,19    signingPrivateKey: Ed25519PrivateKeyParameters,20    nowMs: Long = System.currentTimeMillis(),21): SignedDeviceManifest {22    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }23    val body = DeviceManifestBody(24        manifestVersion = DEVICE_MANIFEST_VERSION,25        issuerDeviceId = issuerDeviceId,26        issuedAt = nowMs,27        devices = devices.mapNotNull(::normaliseManifestEntry),28    )29    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))30    return SignedDeviceManifest(body, pubkeyToHex(signature))31}3233fun verifyDeviceManifest(34    manifest: SignedDeviceManifest,35    issuerSigningPublicKey: ByteArray,36    nowMs: Long = System.currentTimeMillis(),37    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,38): DeviceManifestBody? {39    val body = manifest.body40    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null41    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null42    if (!isHex(manifest.signatureHex, 128)) return null43    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null44    val signature = runCatching { hexToPubkey(manifest.signatureHex) }.getOrNull() ?: return null45    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)46    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null47    return body48}4950fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =51    canonicalise(52        mapOf(53            "devices" to body.devices.map { manifestEntryMap(it) },54            "issuedAt" to body.issuedAt,55            "issuerDeviceId" to body.issuerDeviceId,56            "manifestVersion" to body.manifestVersion,57        ),58    )5960fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {61    val id = deviceId.takeIf { it.isNotBlank() } ?: return null62    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null63    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios", "desktop") } ?: return null64    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null65    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null66    return ManifestDeviceEntry(67        deviceId = id,68        deviceName = name,69        deviceType = type,70        longTermPublicKeyHex = longTerm,71        signingPublicKeyHex = signing,72        wsPort = wsPort,73        pairedAt = pairedAt,74    )75}7677private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {78    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null79    if (entry.deviceType !in setOf("extension", "android", "ios", "desktop")) return null80    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null81    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null82    return entry.copy(83        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),84        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),85    )86}8788private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {89    val normalised = normaliseManifestEntry(entry) ?: entry90    val map = linkedMapOf<String, Any>(91        "deviceId" to normalised.deviceId,92        "deviceName" to normalised.deviceName,93        "deviceType" to normalised.deviceType,94        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),95        "pairedAt" to normalised.pairedAt,96        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),97    )98    normalised.wsPort?.let { map["wsPort"] = it }99    return map100}101102private fun canonicalise(value: Any?): String = when (value) {103    null -> "null"104    is Number -> {105        val d = value.toDouble()106        require(d.isFinite()) { "non-finite number" }107        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()108    }109    is String -> Json.encodeToString(String.serializer(), value)110    is Boolean -> if (value) "true" else "false"111    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }112    is Map<*, *> -> value.entries113        .map { (k, v) -> k.toString() to v }114        .sortedBy { it.first }115        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->116            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)117        }118    else -> error("cannot canonicalise ${value::class.simpleName}")119}120121private fun isHex(value: String, length: Int): Boolean =122    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }