1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters678910const val DEVICE_MANIFEST_VERSION = 0x0211private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L1213interface PairedDeviceRegistry {14 fun getAll(): List<PairedDevice>15 fun get(id: String): PairedDevice?16 fun getByPubkey(pubkeyHex: String): PairedDevice? =17 getAll().firstOrNull {18 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true19 }20 fun upsert(device: PairedDevice)21 fun remove(deviceId: String) {}22 fun listRevocations(): List<DeviceRevocation> = emptyList()2324 25 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()2627 28 fun clearRevocation(deviceId: String) {}29}3031fun buildSignedDeviceManifest(32 issuerDeviceId: String,33 devices: List<ManifestDeviceEntry>,34 signingPrivateKey: Ed25519PrivateKeyParameters,35 nowMs: Long = System.currentTimeMillis(),36 revoked: List<DeviceRevocation> = emptyList(),37): SignedDeviceManifest {38 require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }39 val body = DeviceManifestBody(40 manifestVersion = DEVICE_MANIFEST_VERSION,41 issuerDeviceId = issuerDeviceId,42 issuedAt = nowMs,43 devices = devices.mapNotNull(::normaliseManifestEntry),44 revoked = revoked.map(::normaliseRevocation),45 )46 val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))47 return SignedDeviceManifest(body, pubkeyToHex(signature))48}4950fun verifyDeviceManifest(51 manifest: SignedDeviceManifest,52 issuerSigningPublicKey: ByteArray,53 nowMs: Long = System.currentTimeMillis(),54 freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,55): DeviceManifestBody? {56 val body = manifest.body57 if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null58 if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null59 if (!isHex(manifest.signatureHex, 128)) return null60 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null61 if (!body.revoked.all { isValidRevocation(it) }) return null62 val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null63 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)64 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null65 return body66}6768fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =69 canonicalise(70 mapOf(71 "devices" to body.devices.map { manifestEntryMap(it) },72 "issuedAt" to body.issuedAt,73 "issuerDeviceId" to body.issuerDeviceId,74 "manifestVersion" to body.manifestVersion,75 "revoked" to body.revoked.map { revocationMap(it) },76 ),77 )7879fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {80 val id = deviceId.takeIf { it.isNotBlank() } ?: return null81 val name = deviceName?.takeIf { it.isNotBlank() } ?: return null82 val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null83 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null84 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null85 return ManifestDeviceEntry(86 deviceId = id,87 deviceName = name,88 deviceType = type,89 longTermPublicKeyHex = longTerm,90 signingPublicKeyHex = signing,91 wsPort = wsPort,92 pairedAt = pairedAt,93 )94}9596private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {97 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null98 if (entry.deviceType !in setOf("extension", "android", "ios")) return null99 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null100 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null101 return entry.copy(102 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),103 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),104 )105}106107private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =108 r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())109110private fun isValidRevocation(r: DeviceRevocation): Boolean =111 r.deviceId.isNotBlank() &&112 isHex(r.longTermPublicKeyHex.lowercase(), 64) &&113 r.revokedByDeviceId.isNotBlank()114115private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(116 "deviceId" to r.deviceId,117 "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),118 "revokedAt" to r.revokedAt,119 "revokedByDeviceId" to r.revokedByDeviceId,120)121122private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {123 val normalised = normaliseManifestEntry(entry) ?: entry124 val map = linkedMapOf<String, Any>(125 "deviceId" to normalised.deviceId,126 "deviceName" to normalised.deviceName,127 "deviceType" to normalised.deviceType,128 "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),129 "pairedAt" to normalised.pairedAt,130 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),131 )132 normalised.wsPort?.let { map["wsPort"] = it }133 return map134}135136private fun canonicalise(value: Any?): String = when (value) {137 null -> "null"138 is Number -> {139 val d = value.toDouble()140 require(d.isFinite()) { "non-finite number" }141 if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()142 }143 is String -> Json.encodeToString(String.serializer(), value)144 is Boolean -> if (value) "true" else "false"145 is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }146 is Map<*, *> -> value.entries147 .map { (k, v) -> k.toString() to v }148 .sortedBy { it.first }149 .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->150 Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)151 }152 else -> error("cannot canonicalise ${value::class.simpleName}")153}154155private fun isHex(value: String, length: Int): Boolean =156 value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }