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 = 2562122232425262728internal const val MAX_DEVICE_NAME_CHARS = 64293031internal fun capDeviceName(name: String): String =32 if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name33internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"34353637383940data class DeviceManifestRegistryBatch(41 val incomingRevocations: List<DeviceRevocation>,42 val removals: Set<String>,43 val upserts: List<PairedDevice>,44)4546interface PairedDeviceRegistry {47 fun getAll(): List<PairedDevice>48 fun get(id: String): PairedDevice?49 fun getByPubkey(pubkeyHex: String): PairedDevice? =50 getAll().firstOrNull {51 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true52 }53 fun upsert(device: PairedDevice)54 fun remove(deviceId: String) {}55 fun listRevocations(): List<DeviceRevocation> = emptyList()5657 58 fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean5960 61 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()6263 6465666768697071 fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {72 val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }73 return listRevocations().any {74 it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)75 }76 }7778 79 fun clearRevocation(deviceId: String) {}80}8182fun buildSignedDeviceManifest(83 issuerDeviceId: String,84 devices: List<ManifestDeviceEntry>,85 signingPrivateKey: Ed25519PrivateKeyParameters,86 nowMs: Long = System.currentTimeMillis(),87 revoked: List<DeviceRevocation> = emptyList(),88): SignedDeviceManifest {89 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }90 val body = DeviceManifestBody(91 manifestVersion = DEVICE_MANIFEST_VERSION,92 issuerDeviceId = issuerDeviceId,93 issuedAt = nowMs,94 95 96 97 devices = devices.map { normaliseManifestEntry(it) ?: it },98 revoked = revoked.map(::normaliseRevocation),99 )100 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))101 return SignedDeviceManifest(body, pubkeyToHex(signature))102}103104fun verifyDeviceManifest(105 manifest: SignedDeviceManifest,106 issuerSigningPublicKey: ByteArray,107 nowMs: Long = System.currentTimeMillis(),108 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,109): DeviceManifestBody? {110 val body = manifest.body111 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null112 113 114 115 116 if (body.issuedAt < 0L) return null117 if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null118 if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null119 if (body.devices.size > MAX_MANIFEST_DEVICES) return null120 if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null121 if (!isHex(manifest.signatureHex, 128)) return null122 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null123 if (!body.revoked.all { isValidRevocation(it) }) return null124 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null125 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)126 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null127 128 129 130 return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! })131}132133fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =134 canonicalise(135 mapOf(136 "devices" to body.devices.map { manifestEntryMap(it) },137 "issuedAt" to body.issuedAt,138 "issuerDeviceId" to body.issuerDeviceId,139 "manifestVersion" to body.manifestVersion,140 "revoked" to body.revoked.map { revocationMap(it) },141 ),142 )143144fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {145 val id = deviceId.takeIf { it.isNotBlank() } ?: return null146 val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null147 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null148 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null149 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null150 return ManifestDeviceEntry(151 deviceId = id,152 deviceName = name,153 deviceType = type,154 longTermPublicKeyHex = longTerm,155 signingPublicKeyHex = signing,156 wsPort = wsPort,157 relayUrl = relayUrl,158 pairedAt = pairedAt,159 )160}161162private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {163 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null164 165 166 167 168 if (entry.deviceId.length > MAX_MANIFEST_STRING) return null169 if (entry.deviceType !in setOf("extension", "android", "ios")) return null170 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null171 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null172 if (entry.pairedAt < 0L) return null173 if (174 entry.relayUrl != null && (175 entry.relayUrl.length > MAX_MANIFEST_STRING ||176 runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure177 )178 ) return null179 return entry.copy(180 deviceName = capDeviceName(entry.deviceName),181 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),182 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),183 )184}185186private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =187 r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())188189private fun isValidRevocation(r: DeviceRevocation): Boolean =190 r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&191 isHex(r.longTermPublicKeyHex.lowercase(), 64) &&192 r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&193 r.revokedAt >= 0L194195private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(196 "deviceId" to r.deviceId,197 "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),198 "revokedAt" to r.revokedAt,199 "revokedByDeviceId" to r.revokedByDeviceId,200)201202private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {203 val normalised = normaliseManifestEntry(entry) ?: entry204 val map = linkedMapOf<String, Any>(205 "deviceId" to normalised.deviceId,206 "deviceName" to normalised.deviceName,207 "deviceType" to normalised.deviceType,208 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),209 "pairedAt" to normalised.pairedAt,210 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),211 )212 normalised.wsPort?.let { map["wsPort"] = it }213 normalised.relayUrl?.let { map["relayUrl"] = it }214 return map215}216217private fun canonicalise(value: Any?): String = when (value) {218 null -> "null"219 is Number -> {220 val d = value.toDouble()221 require(d.isFinite()) { "non-finite number" }222 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()223 }224 is String -> Json.encodeToString(String.serializer(), value)225 is Boolean -> if (value) "true" else "false"226 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }227 is Map<*, *> -> value.entries228 .map { (k, v) -> k.toString() to v }229 .sortedBy { it.first }230 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->231 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)232 }233 else -> error("cannot canonicalise ${value::class.simpleName}")234}235236private fun isHex(value: String, length: Int): Boolean =237 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }