package com.peachpasswords.sync import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters const val DEVICE_MANIFEST_VERSION = 0x01 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L interface PairedDeviceRegistry { fun getAll(): List fun get(id: String): PairedDevice? fun getByPubkey(pubkeyHex: String): PairedDevice? = getAll().firstOrNull { it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true } fun upsert(device: PairedDevice) } fun buildSignedDeviceManifest( issuerDeviceId: String, devices: List, signingPrivateKey: Ed25519PrivateKeyParameters, nowMs: Long = System.currentTimeMillis(), ): SignedDeviceManifest { require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" } val body = DeviceManifestBody( manifestVersion = DEVICE_MANIFEST_VERSION, issuerDeviceId = issuerDeviceId, issuedAt = nowMs, devices = devices.mapNotNull(::normaliseManifestEntry), ) val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)) return SignedDeviceManifest(body, pubkeyToHex(signature)) } fun verifyDeviceManifest( manifest: SignedDeviceManifest, issuerSigningPublicKey: ByteArray, nowMs: Long = System.currentTimeMillis(), freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS, ): DeviceManifestBody? { val body = manifest.body if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null if (!isHex(manifest.signatureHex, 128)) return null if (!body.devices.all { normaliseManifestEntry(it) != null }) return null val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8) if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null return body } fun canonicalDeviceManifestBody(body: DeviceManifestBody): String = canonicalise( mapOf( "devices" to body.devices.map { manifestEntryMap(it) }, "issuedAt" to body.issuedAt, "issuerDeviceId" to body.issuerDeviceId, "manifestVersion" to body.manifestVersion, ), ) fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? { val id = deviceId.takeIf { it.isNotBlank() } ?: return null val name = deviceName?.takeIf { it.isNotBlank() } ?: return null val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null return ManifestDeviceEntry( deviceId = id, deviceName = name, deviceType = type, longTermPublicKeyHex = longTerm, signingPublicKeyHex = signing, wsPort = wsPort, pairedAt = pairedAt, ) } private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? { if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null if (entry.deviceType !in setOf("extension", "android", "ios")) return null if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null return entry.copy( longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(), signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(), ) } private fun manifestEntryMap(entry: ManifestDeviceEntry): Map { val normalised = normaliseManifestEntry(entry) ?: entry val map = linkedMapOf( "deviceId" to normalised.deviceId, "deviceName" to normalised.deviceName, "deviceType" to normalised.deviceType, "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(), "pairedAt" to normalised.pairedAt, "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(), ) normalised.wsPort?.let { map["wsPort"] = it } return map } private fun canonicalise(value: Any?): String = when (value) { null -> "null" is Number -> { val d = value.toDouble() require(d.isFinite()) { "non-finite number" } if (d % 1.0 == 0.0) value.toLong().toString() else value.toString() } is String -> Json.encodeToString(String.serializer(), value) is Boolean -> if (value) "true" else "false" is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) } is Map<*, *> -> value.entries .map { (k, v) -> k.toString() to v } .sortedBy { it.first } .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) -> Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v) } else -> error("cannot canonicalise ${value::class.simpleName}") } private fun isHex(value: String, length: Int): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }