DeviceManifestSupport.kt

11.0 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67/** 0x03 adds relay routing to each introduced peer edge. */8const val DEVICE_MANIFEST_VERSION = 0x039private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L10/**11 * Upper bounds on what verifyDeviceManifest will accept. A pathological peer12 * could otherwise craft a manifest that consumes unbounded memory or skips13 * sanity checks (e.g. `issuedAt = Long.MIN_VALUE` bypasses `abs(...) > window`14 * because `abs(Long.MIN_VALUE)` is still negative). These caps match the15 * realistic limits of the pairing protocol: a handful of paired devices, a16 * bounded revocation list, and string fields long enough for any honest use.17 */18private const val MAX_MANIFEST_DEVICES = 25619private const val MAX_MANIFEST_REVOCATIONS = 50020private const val MAX_MANIFEST_STRING = 25621/**22 * Display/storage bound for device names, matching the browser's23 * MAX_DEVICE_NAME_CHARS (shared/sync-limits.ts) and both platforms' local24 * setDeviceName bound. Inbound seams cap rather than reject so old peers25 * with long-but-honest names keep syncing; a device name was never a26 * 256-char string.27 */28internal const val MAX_DEVICE_NAME_CHARS = 642930/** Truncate a peer- or user-supplied device name to the shared bound. */31internal fun capDeviceName(name: String): String =32    if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name33internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"3435/**36 * Fully staged membership change. Implementations must linearize the whole37 * batch; callers hold the vault session epoch fence while invoking it, so a38 * lock/re-unlock cannot observe or leave behind a half-applied manifest.39 */40data class DeviceManifestRegistryBatch(41    val incomingRevocations: List<DeviceRevocation>,42    val removals: Set<String>,43    val upserts: List<PairedDevice>,44)4546interface PairedDeviceRegistry {47    fun getAll(): List<PairedDevice>48    fun get(id: String): PairedDevice?49    fun getByPubkey(pubkeyHex: String): PairedDevice? =50        getAll().firstOrNull {51            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true52        }53    fun upsert(device: PairedDevice)54    fun remove(deviceId: String) {}55    fun listRevocations(): List<DeviceRevocation> = emptyList()5657    /** Commit a complete staged manifest as one logical registry mutation. */58    fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean5960    /** Returns the ids that were not already revoked. */61    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()6263    /**64     * True when either the id or the long-term key is under a terminal65     * revocation. `deviceId` is a self-asserted string in a manifest, so66     * keying terminality on it alone is bypassed by re-vouching a revoked67     * device's key under a fresh id. The key is what a revoked device still68     * holds, so it is what terminality has to follow. `0`×64 is the "no key was69     * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.70     */71    fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {72        val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }73        return listRevocations().any {74            it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)75        }76    }7778    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */79    fun clearRevocation(deviceId: String) {}80}8182fun buildSignedDeviceManifest(83    issuerDeviceId: String,84    devices: List<ManifestDeviceEntry>,85    signingPrivateKey: Ed25519PrivateKeyParameters,86    nowMs: Long = System.currentTimeMillis(),87    revoked: List<DeviceRevocation> = emptyList(),88): SignedDeviceManifest {89    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }90    val body = DeviceManifestBody(91        manifestVersion = DEVICE_MANIFEST_VERSION,92        issuerDeviceId = issuerDeviceId,93        issuedAt = nowMs,94        // Normalise valid entries but keep invalid ones too — verifyDeviceManifest95        // is the gate that detects malformed input (M-09: silent drop would let96        // a caller believe they sent a valid manifest).97        devices = devices.map { normaliseManifestEntry(it) ?: it },98        revoked = revoked.map(::normaliseRevocation),99    )100    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))101    return SignedDeviceManifest(body, pubkeyToHex(signature))102}103104fun verifyDeviceManifest(105    manifest: SignedDeviceManifest,106    issuerSigningPublicKey: ByteArray,107    nowMs: Long = System.currentTimeMillis(),108    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,109): DeviceManifestBody? {110    val body = manifest.body111    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null112    // Two-sided bounds — avoids `abs(...) > window`, which overflows for113    // `Long.MIN_VALUE` and lets a peer-presented timestamp bypass the114    // freshness gate. Reject negative `issuedAt` outright so the bounds115    // arithmetic can't be steered into a wrap-around by the manifest.116    if (body.issuedAt < 0L) return null117    if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null118    if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null119    if (body.devices.size > MAX_MANIFEST_DEVICES) return null120    if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null121    if (!isHex(manifest.signatureHex, 128)) return null122    if (!body.devices.all { normaliseManifestEntry(it) != null }) return null123    if (!body.revoked.all { isValidRevocation(it) }) return null124    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null125    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)126    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null127    // Return entries with the display name capped so every consumer (registry128    // upserts, pending-vouch queue, key-rotation alerts) stores the bounded129    // name. The signature above was verified against the raw body.130    return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! })131}132133fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =134    canonicalise(135        mapOf(136            "devices" to body.devices.map { manifestEntryMap(it) },137            "issuedAt" to body.issuedAt,138            "issuerDeviceId" to body.issuerDeviceId,139            "manifestVersion" to body.manifestVersion,140            "revoked" to body.revoked.map { revocationMap(it) },141        ),142    )143144fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {145    val id = deviceId.takeIf { it.isNotBlank() } ?: return null146    val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null147    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null148    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null149    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null150    return ManifestDeviceEntry(151        deviceId = id,152        deviceName = name,153        deviceType = type,154        longTermPublicKeyHex = longTerm,155        signingPublicKeyHex = signing,156        wsPort = wsPort,157        relayUrl = relayUrl,158        pairedAt = pairedAt,159    )160}161162private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {163    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null164    // Bound string fields — a peer could otherwise send multi-megabyte strings165    // that pass the format checks and bloat the canonical signature input.166    // deviceName is capped (not rejected) at MAX_DEVICE_NAME_CHARS, matching167    // the browser, so a long-but-honest name still syncs.168    if (entry.deviceId.length > MAX_MANIFEST_STRING) return null169    if (entry.deviceType !in setOf("extension", "android", "ios")) return null170    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null171    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null172    if (entry.pairedAt < 0L) return null173    if (174        entry.relayUrl != null && (175            entry.relayUrl.length > MAX_MANIFEST_STRING ||176            runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure177        )178    ) return null179    return entry.copy(180        deviceName = capDeviceName(entry.deviceName),181        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),182        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),183    )184}185186private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =187    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())188189private fun isValidRevocation(r: DeviceRevocation): Boolean =190    r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&191        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&192        r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&193        r.revokedAt >= 0L194195private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(196    "deviceId" to r.deviceId,197    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),198    "revokedAt" to r.revokedAt,199    "revokedByDeviceId" to r.revokedByDeviceId,200)201202private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {203    val normalised = normaliseManifestEntry(entry) ?: entry204    val map = linkedMapOf<String, Any>(205        "deviceId" to normalised.deviceId,206        "deviceName" to normalised.deviceName,207        "deviceType" to normalised.deviceType,208        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),209        "pairedAt" to normalised.pairedAt,210        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),211    )212    normalised.wsPort?.let { map["wsPort"] = it }213    normalised.relayUrl?.let { map["relayUrl"] = it }214    return map215}216217private fun canonicalise(value: Any?): String = when (value) {218    null -> "null"219    is Number -> {220        val d = value.toDouble()221        require(d.isFinite()) { "non-finite number" }222        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()223    }224    is String -> Json.encodeToString(String.serializer(), value)225    is Boolean -> if (value) "true" else "false"226    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }227    is Map<*, *> -> value.entries228        .map { (k, v) -> k.toString() to v }229        .sortedBy { it.first }230        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->231            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)232        }233    else -> error("cannot canonicalise ${value::class.simpleName}")234}235236private fun isHex(value: String, length: Int): Boolean =237    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }