DeviceManifestSupport.kt

7.6 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_000L10internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"1112interface PairedDeviceRegistry {13    fun getAll(): List<PairedDevice>14    fun get(id: String): PairedDevice?15    fun getByPubkey(pubkeyHex: String): PairedDevice? =16        getAll().firstOrNull {17            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true18        }19    fun upsert(device: PairedDevice)20    fun remove(deviceId: String) {}21    fun listRevocations(): List<DeviceRevocation> = emptyList()2223    /** Returns the ids that were not already revoked. */24    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()2526    /**27     * True when either the id or the long-term key is under a terminal28     * revocation. `deviceId` is a self-asserted string in a manifest, so29     * keying terminality on it alone is bypassed by re-vouching a revoked30     * device's key under a fresh id. The key is what a revoked device still31     * holds, so it is what terminality has to follow. `0`×64 is the "no key was32     * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.33     */34    fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {35        val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }36        return listRevocations().any {37            it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)38        }39    }4041    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */42    fun clearRevocation(deviceId: String) {}43}4445fun buildSignedDeviceManifest(46    issuerDeviceId: String,47    devices: List<ManifestDeviceEntry>,48    signingPrivateKey: Ed25519PrivateKeyParameters,49    nowMs: Long = System.currentTimeMillis(),50    revoked: List<DeviceRevocation> = emptyList(),51): SignedDeviceManifest {52    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }53    val body = DeviceManifestBody(54        manifestVersion = DEVICE_MANIFEST_VERSION,55        issuerDeviceId = issuerDeviceId,56        issuedAt = nowMs,57        devices = devices.mapNotNull(::normaliseManifestEntry),58        revoked = revoked.map(::normaliseRevocation),59    )60    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))61    return SignedDeviceManifest(body, pubkeyToHex(signature))62}6364fun verifyDeviceManifest(65    manifest: SignedDeviceManifest,66    issuerSigningPublicKey: ByteArray,67    nowMs: Long = System.currentTimeMillis(),68    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,69): DeviceManifestBody? {70    val body = manifest.body71    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null72    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null73    if (!isHex(manifest.signatureHex, 128)) return null74    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null75    if (!body.revoked.all { isValidRevocation(it) }) return null76    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null77    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)78    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null79    return body80}8182fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =83    canonicalise(84        mapOf(85            "devices" to body.devices.map { manifestEntryMap(it) },86            "issuedAt" to body.issuedAt,87            "issuerDeviceId" to body.issuerDeviceId,88            "manifestVersion" to body.manifestVersion,89            "revoked" to body.revoked.map { revocationMap(it) },90        ),91    )9293fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {94    val id = deviceId.takeIf { it.isNotBlank() } ?: return null95    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null96    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null97    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null98    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null99    return ManifestDeviceEntry(100        deviceId = id,101        deviceName = name,102        deviceType = type,103        longTermPublicKeyHex = longTerm,104        signingPublicKeyHex = signing,105        wsPort = wsPort,106        relayUrl = relayUrl,107        pairedAt = pairedAt,108    )109}110111private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {112    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null113    if (entry.deviceType !in setOf("extension", "android", "ios")) return null114    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null115    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null116    if (117        entry.relayUrl != null &&118        runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure119    ) return null120    return entry.copy(121        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),122        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),123    )124}125126private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =127    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())128129private fun isValidRevocation(r: DeviceRevocation): Boolean =130    r.deviceId.isNotBlank() &&131        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&132        r.revokedByDeviceId.isNotBlank()133134private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(135    "deviceId" to r.deviceId,136    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),137    "revokedAt" to r.revokedAt,138    "revokedByDeviceId" to r.revokedByDeviceId,139)140141private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {142    val normalised = normaliseManifestEntry(entry) ?: entry143    val map = linkedMapOf<String, Any>(144        "deviceId" to normalised.deviceId,145        "deviceName" to normalised.deviceName,146        "deviceType" to normalised.deviceType,147        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),148        "pairedAt" to normalised.pairedAt,149        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),150    )151    normalised.wsPort?.let { map["wsPort"] = it }152    normalised.relayUrl?.let { map["relayUrl"] = it }153    return map154}155156private fun canonicalise(value: Any?): String = when (value) {157    null -> "null"158    is Number -> {159        val d = value.toDouble()160        require(d.isFinite()) { "non-finite number" }161        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()162    }163    is String -> Json.encodeToString(String.serializer(), value)164    is Boolean -> if (value) "true" else "false"165    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }166    is Map<*, *> -> value.entries167        .map { (k, v) -> k.toString() to v }168        .sortedBy { it.first }169        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->170            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)171        }172    else -> error("cannot canonicalise ${value::class.simpleName}")173}174175private fun isHex(value: String, length: Int): Boolean =176    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }