DeviceManifestSupport.kt

6.6 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_000L1213interface PairedDeviceRegistry {14    fun getAll(): List<PairedDevice>15    fun get(id: String): PairedDevice?16    fun getByPubkey(pubkeyHex: String): PairedDevice? =17        getAll().firstOrNull {18            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true19        }20    fun upsert(device: PairedDevice)21    fun remove(deviceId: String) {}22    fun listRevocations(): List<DeviceRevocation> = emptyList()2324    /** Returns the ids that were not already revoked. */25    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()2627    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */28    fun clearRevocation(deviceId: String) {}29}3031fun buildSignedDeviceManifest(32    issuerDeviceId: String,33    devices: List<ManifestDeviceEntry>,34    signingPrivateKey: Ed25519PrivateKeyParameters,35    nowMs: Long = System.currentTimeMillis(),36    revoked: List<DeviceRevocation> = emptyList(),37): SignedDeviceManifest {38    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }39    val body = DeviceManifestBody(40        manifestVersion = DEVICE_MANIFEST_VERSION,41        issuerDeviceId = issuerDeviceId,42        issuedAt = nowMs,43        devices = devices.mapNotNull(::normaliseManifestEntry),44        revoked = revoked.map(::normaliseRevocation),45    )46    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))47    return SignedDeviceManifest(body, pubkeyToHex(signature))48}4950fun verifyDeviceManifest(51    manifest: SignedDeviceManifest,52    issuerSigningPublicKey: ByteArray,53    nowMs: Long = System.currentTimeMillis(),54    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,55): DeviceManifestBody? {56    val body = manifest.body57    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null58    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null59    if (!isHex(manifest.signatureHex, 128)) return null60    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null61    if (!body.revoked.all { isValidRevocation(it) }) return null62    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null63    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)64    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null65    return body66}6768fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =69    canonicalise(70        mapOf(71            "devices" to body.devices.map { manifestEntryMap(it) },72            "issuedAt" to body.issuedAt,73            "issuerDeviceId" to body.issuerDeviceId,74            "manifestVersion" to body.manifestVersion,75            "revoked" to body.revoked.map { revocationMap(it) },76        ),77    )7879fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {80    val id = deviceId.takeIf { it.isNotBlank() } ?: return null81    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null82    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null83    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null84    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null85    return ManifestDeviceEntry(86        deviceId = id,87        deviceName = name,88        deviceType = type,89        longTermPublicKeyHex = longTerm,90        signingPublicKeyHex = signing,91        wsPort = wsPort,92        pairedAt = pairedAt,93    )94}9596private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {97    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null98    if (entry.deviceType !in setOf("extension", "android", "ios")) return null99    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null100    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null101    return entry.copy(102        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),103        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),104    )105}106107private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =108    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())109110private fun isValidRevocation(r: DeviceRevocation): Boolean =111    r.deviceId.isNotBlank() &&112        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&113        r.revokedByDeviceId.isNotBlank()114115private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(116    "deviceId" to r.deviceId,117    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),118    "revokedAt" to r.revokedAt,119    "revokedByDeviceId" to r.revokedByDeviceId,120)121122private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {123    val normalised = normaliseManifestEntry(entry) ?: entry124    val map = linkedMapOf<String, Any>(125        "deviceId" to normalised.deviceId,126        "deviceName" to normalised.deviceName,127        "deviceType" to normalised.deviceType,128        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),129        "pairedAt" to normalised.pairedAt,130        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),131    )132    normalised.wsPort?.let { map["wsPort"] = it }133    return map134}135136private fun canonicalise(value: Any?): String = when (value) {137    null -> "null"138    is Number -> {139        val d = value.toDouble()140        require(d.isFinite()) { "non-finite number" }141        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()142    }143    is String -> Json.encodeToString(String.serializer(), value)144    is Boolean -> if (value) "true" else "false"145    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }146    is Map<*, *> -> value.entries147        .map { (k, v) -> k.toString() to v }148        .sortedBy { it.first }149        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->150            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)151        }152    else -> error("cannot canonicalise ${value::class.simpleName}")153}154155private fun isHex(value: String, length: Int): Boolean =156    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }