DeviceManifestSupport.kt

7.5 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67/** 0x02 added the `revoked` array to the signed body. Coordinated wire change8 *  with peach-browser — the canonical signing input differs, so a 0x01 peer and9 *  a 0x02 peer cannot verify each other's manifests. */10const val DEVICE_MANIFEST_VERSION = 0x0211private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L12internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"1314interface PairedDeviceRegistry {15    fun getAll(): List<PairedDevice>16    fun get(id: String): PairedDevice?17    fun getByPubkey(pubkeyHex: String): PairedDevice? =18        getAll().firstOrNull {19            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true20        }21    fun upsert(device: PairedDevice)22    fun remove(deviceId: String) {}23    fun listRevocations(): List<DeviceRevocation> = emptyList()2425    /** Returns the ids that were not already revoked. */26    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()2728    /**29     * True when either the id or the long-term key is under a terminal30     * revocation. `deviceId` is a self-asserted string in a manifest, so31     * keying terminality on it alone is bypassed by re-vouching a revoked32     * device's key under a fresh id. The key is what a revoked device still33     * holds, so it is what terminality has to follow. `0`×64 is the "no key was34     * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.35     */36    fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {37        val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }38        return listRevocations().any {39            it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)40        }41    }4243    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */44    fun clearRevocation(deviceId: String) {}45}4647fun buildSignedDeviceManifest(48    issuerDeviceId: String,49    devices: List<ManifestDeviceEntry>,50    signingPrivateKey: Ed25519PrivateKeyParameters,51    nowMs: Long = System.currentTimeMillis(),52    revoked: List<DeviceRevocation> = emptyList(),53): SignedDeviceManifest {54    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }55    val body = DeviceManifestBody(56        manifestVersion = DEVICE_MANIFEST_VERSION,57        issuerDeviceId = issuerDeviceId,58        issuedAt = nowMs,59        devices = devices.mapNotNull(::normaliseManifestEntry),60        revoked = revoked.map(::normaliseRevocation),61    )62    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))63    return SignedDeviceManifest(body, pubkeyToHex(signature))64}6566fun verifyDeviceManifest(67    manifest: SignedDeviceManifest,68    issuerSigningPublicKey: ByteArray,69    nowMs: Long = System.currentTimeMillis(),70    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,71): DeviceManifestBody? {72    val body = manifest.body73    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null74    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null75    if (!isHex(manifest.signatureHex, 128)) return null76    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null77    if (!body.revoked.all { isValidRevocation(it) }) return null78    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null79    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)80    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null81    return body82}8384fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =85    canonicalise(86        mapOf(87            "devices" to body.devices.map { manifestEntryMap(it) },88            "issuedAt" to body.issuedAt,89            "issuerDeviceId" to body.issuerDeviceId,90            "manifestVersion" to body.manifestVersion,91            "revoked" to body.revoked.map { revocationMap(it) },92        ),93    )9495fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {96    val id = deviceId.takeIf { it.isNotBlank() } ?: return null97    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null98    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null99    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null100    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null101    return ManifestDeviceEntry(102        deviceId = id,103        deviceName = name,104        deviceType = type,105        longTermPublicKeyHex = longTerm,106        signingPublicKeyHex = signing,107        wsPort = wsPort,108        pairedAt = pairedAt,109    )110}111112private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {113    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null114    if (entry.deviceType !in setOf("extension", "android", "ios")) return null115    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null116    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null117    return entry.copy(118        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),119        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),120    )121}122123private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =124    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())125126private fun isValidRevocation(r: DeviceRevocation): Boolean =127    r.deviceId.isNotBlank() &&128        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&129        r.revokedByDeviceId.isNotBlank()130131private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(132    "deviceId" to r.deviceId,133    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),134    "revokedAt" to r.revokedAt,135    "revokedByDeviceId" to r.revokedByDeviceId,136)137138private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {139    val normalised = normaliseManifestEntry(entry) ?: entry140    val map = linkedMapOf<String, Any>(141        "deviceId" to normalised.deviceId,142        "deviceName" to normalised.deviceName,143        "deviceType" to normalised.deviceType,144        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),145        "pairedAt" to normalised.pairedAt,146        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),147    )148    normalised.wsPort?.let { map["wsPort"] = it }149    return map150}151152private fun canonicalise(value: Any?): String = when (value) {153    null -> "null"154    is Number -> {155        val d = value.toDouble()156        require(d.isFinite()) { "non-finite number" }157        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()158    }159    is String -> Json.encodeToString(String.serializer(), value)160    is Boolean -> if (value) "true" else "false"161    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }162    is Map<*, *> -> value.entries163        .map { (k, v) -> k.toString() to v }164        .sortedBy { it.first }165        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->166            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)167        }168    else -> error("cannot canonicalise ${value::class.simpleName}")169}170171private fun isHex(value: String, length: Int): Boolean =172    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }