1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters678const val DEVICE_MANIFEST_VERSION = 0x039private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L101112131415161718private const val MAX_MANIFEST_DEVICES = 25619private const val MAX_MANIFEST_REVOCATIONS = 50020private const val MAX_MANIFEST_STRING = 25621internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"22232425262728data class DeviceManifestRegistryBatch(29 val incomingRevocations: List<DeviceRevocation>,30 val removals: Set<String>,31 val upserts: List<PairedDevice>,32)3334interface PairedDeviceRegistry {35 fun getAll(): List<PairedDevice>36 fun get(id: String): PairedDevice?37 fun getByPubkey(pubkeyHex: String): PairedDevice? =38 getAll().firstOrNull {39 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true40 }41 fun upsert(device: PairedDevice)42 fun remove(deviceId: String) {}43 fun listRevocations(): List<DeviceRevocation> = emptyList()4445 46 fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean4748 49 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()5051 5253545556575859 fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {60 val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }61 return listRevocations().any {62 it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)63 }64 }6566 67 fun clearRevocation(deviceId: String) {}68}6970fun buildSignedDeviceManifest(71 issuerDeviceId: String,72 devices: List<ManifestDeviceEntry>,73 signingPrivateKey: Ed25519PrivateKeyParameters,74 nowMs: Long = System.currentTimeMillis(),75 revoked: List<DeviceRevocation> = emptyList(),76): SignedDeviceManifest {77 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }78 val body = DeviceManifestBody(79 manifestVersion = DEVICE_MANIFEST_VERSION,80 issuerDeviceId = issuerDeviceId,81 issuedAt = nowMs,82 83 84 85 devices = devices.map { normaliseManifestEntry(it) ?: it },86 revoked = revoked.map(::normaliseRevocation),87 )88 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))89 return SignedDeviceManifest(body, pubkeyToHex(signature))90}9192fun verifyDeviceManifest(93 manifest: SignedDeviceManifest,94 issuerSigningPublicKey: ByteArray,95 nowMs: Long = System.currentTimeMillis(),96 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,97): DeviceManifestBody? {98 val body = manifest.body99 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null100 101 102 103 104 if (body.issuedAt < 0L) return null105 if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null106 if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null107 if (body.devices.size > MAX_MANIFEST_DEVICES) return null108 if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null109 if (!isHex(manifest.signatureHex, 128)) return null110 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null111 if (!body.revoked.all { isValidRevocation(it) }) return null112 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null113 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)114 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null115 return body116}117118fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =119 canonicalise(120 mapOf(121 "devices" to body.devices.map { manifestEntryMap(it) },122 "issuedAt" to body.issuedAt,123 "issuerDeviceId" to body.issuerDeviceId,124 "manifestVersion" to body.manifestVersion,125 "revoked" to body.revoked.map { revocationMap(it) },126 ),127 )128129fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {130 val id = deviceId.takeIf { it.isNotBlank() } ?: return null131 val name = deviceName?.takeIf { it.isNotBlank() } ?: return null132 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null133 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null134 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null135 return ManifestDeviceEntry(136 deviceId = id,137 deviceName = name,138 deviceType = type,139 longTermPublicKeyHex = longTerm,140 signingPublicKeyHex = signing,141 wsPort = wsPort,142 relayUrl = relayUrl,143 pairedAt = pairedAt,144 )145}146147private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {148 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null149 150 151 if (entry.deviceId.length > MAX_MANIFEST_STRING || entry.deviceName.length > MAX_MANIFEST_STRING) return null152 if (entry.deviceType !in setOf("extension", "android", "ios")) return null153 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null154 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null155 if (entry.pairedAt < 0L) return null156 if (157 entry.relayUrl != null && (158 entry.relayUrl.length > MAX_MANIFEST_STRING ||159 runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure160 )161 ) return null162 return entry.copy(163 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),164 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),165 )166}167168private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =169 r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())170171private fun isValidRevocation(r: DeviceRevocation): Boolean =172 r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&173 isHex(r.longTermPublicKeyHex.lowercase(), 64) &&174 r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&175 r.revokedAt >= 0L176177private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(178 "deviceId" to r.deviceId,179 "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),180 "revokedAt" to r.revokedAt,181 "revokedByDeviceId" to r.revokedByDeviceId,182)183184private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {185 val normalised = normaliseManifestEntry(entry) ?: entry186 val map = linkedMapOf<String, Any>(187 "deviceId" to normalised.deviceId,188 "deviceName" to normalised.deviceName,189 "deviceType" to normalised.deviceType,190 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),191 "pairedAt" to normalised.pairedAt,192 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),193 )194 normalised.wsPort?.let { map["wsPort"] = it }195 normalised.relayUrl?.let { map["relayUrl"] = it }196 return map197}198199private fun canonicalise(value: Any?): String = when (value) {200 null -> "null"201 is Number -> {202 val d = value.toDouble()203 require(d.isFinite()) { "non-finite number" }204 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()205 }206 is String -> Json.encodeToString(String.serializer(), value)207 is Boolean -> if (value) "true" else "false"208 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }209 is Map<*, *> -> value.entries210 .map { (k, v) -> k.toString() to v }211 .sortedBy { it.first }212 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->213 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)214 }215 else -> error("cannot canonicalise ${value::class.simpleName}")216}217218private fun isHex(value: String, length: Int): Boolean =219 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }