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"2223interface PairedDeviceRegistry {24 fun getAll(): List<PairedDevice>25 fun get(id: String): PairedDevice?26 fun getByPubkey(pubkeyHex: String): PairedDevice? =27 getAll().firstOrNull {28 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true29 }30 fun upsert(device: PairedDevice)31 fun remove(deviceId: String) {}32 fun listRevocations(): List<DeviceRevocation> = emptyList()3334 35 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()3637 3839404142434445 fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {46 val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }47 return listRevocations().any {48 it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)49 }50 }5152 53 fun clearRevocation(deviceId: String) {}54}5556fun buildSignedDeviceManifest(57 issuerDeviceId: String,58 devices: List<ManifestDeviceEntry>,59 signingPrivateKey: Ed25519PrivateKeyParameters,60 nowMs: Long = System.currentTimeMillis(),61 revoked: List<DeviceRevocation> = emptyList(),62): SignedDeviceManifest {63 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }64 val body = DeviceManifestBody(65 manifestVersion = DEVICE_MANIFEST_VERSION,66 issuerDeviceId = issuerDeviceId,67 issuedAt = nowMs,68 69 70 71 devices = devices.map { normaliseManifestEntry(it) ?: it },72 revoked = revoked.map(::normaliseRevocation),73 )74 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))75 return SignedDeviceManifest(body, pubkeyToHex(signature))76}7778fun verifyDeviceManifest(79 manifest: SignedDeviceManifest,80 issuerSigningPublicKey: ByteArray,81 nowMs: Long = System.currentTimeMillis(),82 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,83): DeviceManifestBody? {84 val body = manifest.body85 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null86 87 88 89 90 if (body.issuedAt < 0L) return null91 if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null92 if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null93 if (body.devices.size > MAX_MANIFEST_DEVICES) return null94 if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null95 if (!isHex(manifest.signatureHex, 128)) return null96 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null97 if (!body.revoked.all { isValidRevocation(it) }) return null98 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null99 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)100 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null101 return body102}103104fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =105 canonicalise(106 mapOf(107 "devices" to body.devices.map { manifestEntryMap(it) },108 "issuedAt" to body.issuedAt,109 "issuerDeviceId" to body.issuerDeviceId,110 "manifestVersion" to body.manifestVersion,111 "revoked" to body.revoked.map { revocationMap(it) },112 ),113 )114115fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {116 val id = deviceId.takeIf { it.isNotBlank() } ?: return null117 val name = deviceName?.takeIf { it.isNotBlank() } ?: return null118 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null119 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null120 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null121 return ManifestDeviceEntry(122 deviceId = id,123 deviceName = name,124 deviceType = type,125 longTermPublicKeyHex = longTerm,126 signingPublicKeyHex = signing,127 wsPort = wsPort,128 relayUrl = relayUrl,129 pairedAt = pairedAt,130 )131}132133private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {134 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null135 136 137 if (entry.deviceId.length > MAX_MANIFEST_STRING || entry.deviceName.length > MAX_MANIFEST_STRING) return null138 if (entry.deviceType !in setOf("extension", "android", "ios")) return null139 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null140 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null141 if (entry.pairedAt < 0L) return null142 if (143 entry.relayUrl != null && (144 entry.relayUrl.length > MAX_MANIFEST_STRING ||145 runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure146 )147 ) return null148 return entry.copy(149 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),150 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),151 )152}153154private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =155 r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())156157private fun isValidRevocation(r: DeviceRevocation): Boolean =158 r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&159 isHex(r.longTermPublicKeyHex.lowercase(), 64) &&160 r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&161 r.revokedAt >= 0L162163private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(164 "deviceId" to r.deviceId,165 "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),166 "revokedAt" to r.revokedAt,167 "revokedByDeviceId" to r.revokedByDeviceId,168)169170private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {171 val normalised = normaliseManifestEntry(entry) ?: entry172 val map = linkedMapOf<String, Any>(173 "deviceId" to normalised.deviceId,174 "deviceName" to normalised.deviceName,175 "deviceType" to normalised.deviceType,176 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),177 "pairedAt" to normalised.pairedAt,178 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),179 )180 normalised.wsPort?.let { map["wsPort"] = it }181 normalised.relayUrl?.let { map["relayUrl"] = it }182 return map183}184185private fun canonicalise(value: Any?): String = when (value) {186 null -> "null"187 is Number -> {188 val d = value.toDouble()189 require(d.isFinite()) { "non-finite number" }190 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()191 }192 is String -> Json.encodeToString(String.serializer(), value)193 is Boolean -> if (value) "true" else "false"194 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }195 is Map<*, *> -> value.entries196 .map { (k, v) -> k.toString() to v }197 .sortedBy { it.first }198 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->199 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)200 }201 else -> error("cannot canonicalise ${value::class.simpleName}")202}203204private fun isHex(value: String, length: Int): Boolean =205 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }