DeviceManifestSupport.kt

9.2 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67/** 0x03 adds relay routing to each introduced peer edge. */8const val DEVICE_MANIFEST_VERSION = 0x039private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L10/**11 * Upper bounds on what verifyDeviceManifest will accept. A pathological peer12 * could otherwise craft a manifest that consumes unbounded memory or skips13 * sanity checks (e.g. `issuedAt = Long.MIN_VALUE` bypasses `abs(...) > window`14 * because `abs(Long.MIN_VALUE)` is still negative). These caps match the15 * realistic limits of the pairing protocol: a handful of paired devices, a16 * bounded revocation list, and string fields long enough for any honest use.17 */18private const val MAX_MANIFEST_DEVICES = 25619private const val MAX_MANIFEST_REVOCATIONS = 50020private const val MAX_MANIFEST_STRING = 25621internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"2223interface PairedDeviceRegistry {24    fun getAll(): List<PairedDevice>25    fun get(id: String): PairedDevice?26    fun getByPubkey(pubkeyHex: String): PairedDevice? =27        getAll().firstOrNull {28            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true29        }30    fun upsert(device: PairedDevice)31    fun remove(deviceId: String) {}32    fun listRevocations(): List<DeviceRevocation> = emptyList()3334    /** Returns the ids that were not already revoked. */35    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()3637    /**38     * True when either the id or the long-term key is under a terminal39     * revocation. `deviceId` is a self-asserted string in a manifest, so40     * keying terminality on it alone is bypassed by re-vouching a revoked41     * device's key under a fresh id. The key is what a revoked device still42     * holds, so it is what terminality has to follow. `0`×64 is the "no key was43     * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.44     */45    fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {46        val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }47        return listRevocations().any {48            it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)49        }50    }5152    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */53    fun clearRevocation(deviceId: String) {}54}5556fun buildSignedDeviceManifest(57    issuerDeviceId: String,58    devices: List<ManifestDeviceEntry>,59    signingPrivateKey: Ed25519PrivateKeyParameters,60    nowMs: Long = System.currentTimeMillis(),61    revoked: List<DeviceRevocation> = emptyList(),62): SignedDeviceManifest {63    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }64    val body = DeviceManifestBody(65        manifestVersion = DEVICE_MANIFEST_VERSION,66        issuerDeviceId = issuerDeviceId,67        issuedAt = nowMs,68        devices = devices.mapNotNull(::normaliseManifestEntry),69        revoked = revoked.map(::normaliseRevocation),70    )71    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))72    return SignedDeviceManifest(body, pubkeyToHex(signature))73}7475fun verifyDeviceManifest(76    manifest: SignedDeviceManifest,77    issuerSigningPublicKey: ByteArray,78    nowMs: Long = System.currentTimeMillis(),79    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,80): DeviceManifestBody? {81    val body = manifest.body82    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null83    // Two-sided bounds — avoids `abs(...) > window`, which overflows for84    // `Long.MIN_VALUE` and lets a peer-presented timestamp bypass the85    // freshness gate. Reject negative `issuedAt` outright so the bounds86    // arithmetic can't be steered into a wrap-around by the manifest.87    if (body.issuedAt < 0L) return null88    if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null89    if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null90    if (body.devices.size > MAX_MANIFEST_DEVICES) return null91    if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null92    if (!isHex(manifest.signatureHex, 128)) return null93    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null94    if (!body.revoked.all { isValidRevocation(it) }) return null95    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null96    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)97    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null98    return body99}100101fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =102    canonicalise(103        mapOf(104            "devices" to body.devices.map { manifestEntryMap(it) },105            "issuedAt" to body.issuedAt,106            "issuerDeviceId" to body.issuerDeviceId,107            "manifestVersion" to body.manifestVersion,108            "revoked" to body.revoked.map { revocationMap(it) },109        ),110    )111112fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {113    val id = deviceId.takeIf { it.isNotBlank() } ?: return null114    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null115    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null116    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null117    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null118    return ManifestDeviceEntry(119        deviceId = id,120        deviceName = name,121        deviceType = type,122        longTermPublicKeyHex = longTerm,123        signingPublicKeyHex = signing,124        wsPort = wsPort,125        relayUrl = relayUrl,126        pairedAt = pairedAt,127    )128}129130private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {131    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null132    // Bound string fields — a peer could otherwise send multi-megabyte strings133    // that pass the format checks and bloat the canonical signature input.134    if (entry.deviceId.length > MAX_MANIFEST_STRING || entry.deviceName.length > MAX_MANIFEST_STRING) return null135    if (entry.deviceType !in setOf("extension", "android", "ios")) return null136    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null137    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null138    if (entry.pairedAt < 0L) return null139    if (140        entry.relayUrl != null && (141            entry.relayUrl.length > MAX_MANIFEST_STRING ||142            runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure143        )144    ) return null145    return entry.copy(146        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),147        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),148    )149}150151private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =152    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())153154private fun isValidRevocation(r: DeviceRevocation): Boolean =155    r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&156        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&157        r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&158        r.revokedAt >= 0L159160private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(161    "deviceId" to r.deviceId,162    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),163    "revokedAt" to r.revokedAt,164    "revokedByDeviceId" to r.revokedByDeviceId,165)166167private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {168    val normalised = normaliseManifestEntry(entry) ?: entry169    val map = linkedMapOf<String, Any>(170        "deviceId" to normalised.deviceId,171        "deviceName" to normalised.deviceName,172        "deviceType" to normalised.deviceType,173        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),174        "pairedAt" to normalised.pairedAt,175        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),176    )177    normalised.wsPort?.let { map["wsPort"] = it }178    normalised.relayUrl?.let { map["relayUrl"] = it }179    return map180}181182private fun canonicalise(value: Any?): String = when (value) {183    null -> "null"184    is Number -> {185        val d = value.toDouble()186        require(d.isFinite()) { "non-finite number" }187        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()188    }189    is String -> Json.encodeToString(String.serializer(), value)190    is Boolean -> if (value) "true" else "false"191    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }192    is Map<*, *> -> value.entries193        .map { (k, v) -> k.toString() to v }194        .sortedBy { it.first }195        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->196            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)197        }198    else -> error("cannot canonicalise ${value::class.simpleName}")199}200201private fun isHex(value: String, length: Int): Boolean =202    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }