1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters678const val DEVICE_MANIFEST_VERSION = 0x059const val MAX_VOUCH_DEPTH = 310private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L111213141516171819private const val MAX_MANIFEST_DEVICES = 25620private const val MAX_MANIFEST_REVOCATIONS = 50021private const val MAX_MANIFEST_STRING = 2562223242526272829internal const val MAX_DEVICE_NAME_CHARS = 64303132internal fun capDeviceName(name: String): String =33 if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name34internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"35363738394041data class DeviceManifestRegistryBatch(42 val incomingRevocations: List<DeviceRevocation>,43 val removals: Set<String>,44 val upserts: List<PairedDevice>,45)4647interface PairedDeviceRegistry {48 fun getAll(): List<PairedDevice>49 fun get(id: String): PairedDevice?50 fun getByPubkey(pubkeyHex: String): PairedDevice? =51 getAll().firstOrNull {52 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true53 }54 fun upsert(device: PairedDevice)55 fun remove(deviceId: String) {}56 fun listRevocations(): List<DeviceRevocation> = emptyList()5758 59606162 fun issuerSigningKeys(issuerDeviceId: String): Set<String> = emptySet()63 fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean = true6465 66 fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean6768 69 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()7071 7273747576777879 fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {80 val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }81 return listRevocations().any {82 it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)83 }84 }8586 87 fun clearRevocation(deviceId: String) {}88}8990fun buildSignedDeviceManifest(91 issuerDeviceId: String,92 devices: List<ManifestDeviceEntry>,93 signingPrivateKey: Ed25519PrivateKeyParameters,94 nowMs: Long = System.currentTimeMillis(),95 revoked: List<DeviceRevocation> = emptyList(),96): SignedDeviceManifest {97 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }98 val body = DeviceManifestBody(99 manifestVersion = DEVICE_MANIFEST_VERSION,100 issuerDeviceId = issuerDeviceId,101 issuedAt = nowMs,102 devices = devices.map { normaliseManifestEntry(it) ?: it },103 revoked = revoked.map { record ->104 105 106 107 if (record.signatureHex.isNotBlank()) record else signDeviceRevocation(record, signingPrivateKey)108 },109 )110 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))111 return SignedDeviceManifest(body, pubkeyToHex(signature))112}113114115fun signDeviceRevocation(116 record: DeviceRevocation,117 signingPrivateKey: Ed25519PrivateKeyParameters,118): DeviceRevocation {119 val unsigned = record.copy(signatureHex = "")120 val signature = signEd25519(121 signingPrivateKey,122 canonicalDeviceRevocation(unsigned).toByteArray(Charsets.UTF_8),123 )124 return unsigned.copy(signatureHex = pubkeyToHex(signature))125}126127128fun canonicalDeviceRevocation(record: DeviceRevocation): String =129 canonicalise(130 mapOf(131 "deviceId" to record.deviceId,132 "longTermPublicKeyHex" to record.longTermPublicKeyHex.lowercase(),133 "recordVersion" to record.recordVersion,134 "revokedAt" to record.revokedAt,135 "revokedByDeviceId" to record.revokedByDeviceId,136 ),137 )138139fun verifyDeviceRevocation(record: DeviceRevocation, issuerSigningPublicKey: ByteArray): Boolean {140 if (!isHex(record.signatureHex, 128)) return false141 val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false142 return verifyEd25519(143 issuerSigningPublicKey,144 canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8),145 signature,146 )147}148149150151152153154155156157158159160private fun isAuthorisedRevocation(161 record: DeviceRevocation,162 resolveIssuerSigningKeys: (String) -> Set<String>,163): Boolean {164 val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false165 val payload = canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8)166 val trusted = resolveIssuerSigningKeys(record.revokedByDeviceId)167 if (trusted.isEmpty()) return false168 return trusted.any { key ->169 val hex = key.lowercase()170 isHex(hex, 64) && verifyEd25519(hexToPubkey(hex), payload, signature)171 }172}173174fun verifyDeviceManifest(175 manifest: SignedDeviceManifest,176 issuerSigningPublicKey: ByteArray,177 resolveIssuerSigningKeys: (String) -> Set<String>,178 nowMs: Long = System.currentTimeMillis(),179 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,180): DeviceManifestBody? {181 val body = manifest.body182 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null183 184 185 186 187 if (body.issuedAt < 0L) return null188 if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null189 if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null190 if (body.devices.size > MAX_MANIFEST_DEVICES) return null191 if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null192 193 194 195 if (body.devices.any { normaliseManifestEntry(it) == null }) return null196 if (!isHex(manifest.signatureHex, 128)) return null197 if (!body.revoked.all { isValidRevocation(it) }) return null198 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null199 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)200 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null201 202 203 204 205 206 207 if (!body.revoked.all { record -> isAuthorisedRevocation(record, resolveIssuerSigningKeys) }) return null208 209 210 211 return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! })212}213214fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =215 canonicalise(216 mapOf(217 "devices" to body.devices.map { manifestEntryMap(it) },218 "issuedAt" to body.issuedAt,219 "issuerDeviceId" to body.issuerDeviceId,220 "manifestVersion" to body.manifestVersion,221 "revoked" to body.revoked.map { revocationMap(it) },222 ),223 )224225fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {226 val id = deviceId.takeIf { it.isNotBlank() } ?: return null227 val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null228 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null229 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null230 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null231 return ManifestDeviceEntry(232 deviceId = id,233 deviceName = name,234 deviceType = type,235 longTermPublicKeyHex = longTerm,236 signingPublicKeyHex = signing,237 wsPort = wsPort,238 relayUrl = relayUrl,239 pairedAt = pairedAt,240 vouchDepth = 0,241 )242}243244private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {245 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null246 if (entry.deviceId.length > MAX_MANIFEST_STRING) return null247 if (entry.deviceType !in setOf("extension", "android", "ios")) return null248 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null249 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null250 if (entry.pairedAt < 0L || entry.vouchDepth !in 0..MAX_VOUCH_DEPTH) return null251 if (252 entry.relayUrl != null && (253 entry.relayUrl.length > MAX_MANIFEST_STRING ||254 runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure255 )256 ) return null257 return entry.copy(258 deviceName = capDeviceName(entry.deviceName),259 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),260 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),261 )262}263264private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =265 r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())266267private fun isValidRevocation(r: DeviceRevocation): Boolean =268 r.recordVersion == DEVICE_REVOCATION_RECORD_VERSION &&269 r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&270 isHex(r.longTermPublicKeyHex.lowercase(), 64) &&271 r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&272 isHex(r.signatureHex, 128) &&273 r.revokedAt >= 0L274 275276private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(277 "deviceId" to r.deviceId,278 "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),279 "recordVersion" to r.recordVersion,280 "revokedAt" to r.revokedAt,281 "revokedByDeviceId" to r.revokedByDeviceId,282 "signatureHex" to r.signatureHex.lowercase(),283)284285private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {286 val normalised = normaliseManifestEntry(entry) ?: entry287 val map = linkedMapOf<String, Any>(288 "deviceId" to normalised.deviceId,289 "deviceName" to normalised.deviceName,290 "deviceType" to normalised.deviceType,291 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),292 "pairedAt" to normalised.pairedAt,293 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),294 "vouchDepth" to normalised.vouchDepth,295 )296 normalised.wsPort?.let { map["wsPort"] = it }297 normalised.relayUrl?.let { map["relayUrl"] = it }298 return map299}300301private fun canonicalise(value: Any?): String = when (value) {302 null -> "null"303 is Number -> {304 val d = value.toDouble()305 require(d.isFinite()) { "non-finite number" }306 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()307 }308 is String -> Json.encodeToString(String.serializer(), value)309 is Boolean -> if (value) "true" else "false"310 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }311 is Map<*, *> -> value.entries312 .map { (k, v) -> k.toString() to v }313 .sortedBy { it.first }314 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->315 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)316 }317 else -> error("cannot canonicalise ${value::class.simpleName}")318}319320private fun isHex(value: String, length: Int): Boolean =321 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }