1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67const val DEVICE_MANIFEST_VERSION = 0x018private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L910interface PairedDeviceRegistry {11 fun getAll(): List<PairedDevice>12 fun get(id: String): PairedDevice?13 fun getByPubkey(pubkeyHex: String): PairedDevice? =14 getAll().firstOrNull {15 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true16 }17 fun upsert(device: PairedDevice)18}1920fun buildSignedDeviceManifest(21 issuerDeviceId: String,22 devices: List<ManifestDeviceEntry>,23 signingPrivateKey: Ed25519PrivateKeyParameters,24 nowMs: Long = System.currentTimeMillis(),25): SignedDeviceManifest {26 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }27 val body = DeviceManifestBody(28 manifestVersion = DEVICE_MANIFEST_VERSION,29 issuerDeviceId = issuerDeviceId,30 issuedAt = nowMs,31 devices = devices.mapNotNull(::normaliseManifestEntry),32 )33 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))34 return SignedDeviceManifest(body, pubkeyToHex(signature))35}3637fun verifyDeviceManifest(38 manifest: SignedDeviceManifest,39 issuerSigningPublicKey: ByteArray,40 nowMs: Long = System.currentTimeMillis(),41 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,42): DeviceManifestBody? {43 val body = manifest.body44 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null45 if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null46 if (!isHex(manifest.signatureHex, 128)) return null47 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null48 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null49 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)50 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null51 return body52}5354fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =55 canonicalise(56 mapOf(57 "devices" to body.devices.map { manifestEntryMap(it) },58 "issuedAt" to body.issuedAt,59 "issuerDeviceId" to body.issuerDeviceId,60 "manifestVersion" to body.manifestVersion,61 ),62 )6364fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {65 val id = deviceId.takeIf { it.isNotBlank() } ?: return null66 val name = deviceName?.takeIf { it.isNotBlank() } ?: return null67 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null68 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null69 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null70 return ManifestDeviceEntry(71 deviceId = id,72 deviceName = name,73 deviceType = type,74 longTermPublicKeyHex = longTerm,75 signingPublicKeyHex = signing,76 wsPort = wsPort,77 pairedAt = pairedAt,78 )79}8081private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {82 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null83 if (entry.deviceType !in setOf("extension", "android", "ios")) return null84 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null85 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null86 return entry.copy(87 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),88 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),89 )90}9192private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {93 val normalised = normaliseManifestEntry(entry) ?: entry94 val map = linkedMapOf<String, Any>(95 "deviceId" to normalised.deviceId,96 "deviceName" to normalised.deviceName,97 "deviceType" to normalised.deviceType,98 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),99 "pairedAt" to normalised.pairedAt,100 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),101 )102 normalised.wsPort?.let { map["wsPort"] = it }103 return map104}105106private fun canonicalise(value: Any?): String = when (value) {107 null -> "null"108 is Number -> {109 val d = value.toDouble()110 require(d.isFinite()) { "non-finite number" }111 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()112 }113 is String -> Json.encodeToString(String.serializer(), value)114 is Boolean -> if (value) "true" else "false"115 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }116 is Map<*, *> -> value.entries117 .map { (k, v) -> k.toString() to v }118 .sortedBy { it.first }119 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->120 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)121 }122 else -> error("cannot canonicalise ${value::class.simpleName}")123}124125private fun isHex(value: String, length: Int): Boolean =126 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }