mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
90 lines
3.7 KiB
Kotlin
90 lines
3.7 KiB
Kotlin
package com.bitchat.android.nostr
|
|
|
|
internal data class NdrApplicationMessage(
|
|
val content: String,
|
|
val timestampMs: Long,
|
|
val expiresAtSeconds: Long?
|
|
) {
|
|
fun isExpiredAt(nowSeconds: Long): Boolean =
|
|
expiresAtSeconds?.let { it <= nowSeconds } == true
|
|
}
|
|
|
|
internal object NdrApplicationMessageDecoder {
|
|
private const val PROTOCOL_TAG = "ndr-protocol"
|
|
private const val PROTOCOL_VALUE = "pairwise-rumor"
|
|
private const val VERSION_TAG = "ndr-version"
|
|
private const val VERSION_VALUE = "1"
|
|
private const val MILLISECOND_TIMESTAMP_TAG = "ms"
|
|
private const val EXPIRATION_TAG = "expiration"
|
|
private val UNSIGNED_DECIMAL = Regex("^[0-9]+$")
|
|
|
|
fun decode(message: NdrDecryptedMessage): NdrApplicationMessage? =
|
|
runCatching { decodeStrict(message) }.getOrNull()
|
|
|
|
private fun decodeStrict(message: NdrDecryptedMessage): NdrApplicationMessage? {
|
|
val plaintext = message.content.trim()
|
|
if (!NdrInputPolicy.isWithinEncodedEventLimit(plaintext) ||
|
|
!NdrInputPolicy.isPubkeyHex(message.senderPubkeyHex) ||
|
|
!NdrInputPolicy.isEventIdHex(message.eventId)
|
|
) return null
|
|
|
|
val event = NostrEvent.fromJsonString(plaintext) ?: return null
|
|
if (event.kind != NostrKind.DIRECT_MESSAGE) return null
|
|
if (event.sig != null) return null
|
|
if (!NdrInputPolicy.isPubkeyHex(event.pubkey)) return null
|
|
if (!event.pubkey.equals(message.senderPubkeyHex, ignoreCase = true)) return null
|
|
if (event.createdAt <= 0 || event.id.isBlank()) return null
|
|
if (!event.id.equals(event.computeEventIdHex(), ignoreCase = true)) return null
|
|
if (!message.eventId.equals(event.id, ignoreCase = true)) return null
|
|
if (!NdrInputPolicy.hasBoundedTags(event)) return null
|
|
if (!event.hasExactlyOneTag(PROTOCOL_TAG, PROTOCOL_VALUE)) return null
|
|
if (!event.hasExactlyOneTag(VERSION_TAG, VERSION_VALUE)) return null
|
|
val timestampMs = event.requiredMillisecondTimestamp() ?: return null
|
|
val expiresAtSeconds = event.optionalExpirationSeconds() ?: run {
|
|
if (event.tags.any { it.firstOrNull() == EXPIRATION_TAG }) return null
|
|
null
|
|
}
|
|
val actionExpiresAtSeconds = message.expiresAtSeconds?.let {
|
|
if (it > Long.MAX_VALUE.toULong()) return null
|
|
it.toLong()
|
|
}
|
|
if (actionExpiresAtSeconds != expiresAtSeconds) return null
|
|
|
|
return NdrApplicationMessage(
|
|
content = event.content,
|
|
timestampMs = timestampMs,
|
|
expiresAtSeconds = expiresAtSeconds
|
|
)
|
|
}
|
|
|
|
private fun NostrEvent.hasExactlyOneTag(name: String, value: String): Boolean {
|
|
val matches = tags.filter { it.firstOrNull() == name }
|
|
return matches.size == 1 &&
|
|
matches.single().size == 2 &&
|
|
matches.single()[1] == value
|
|
}
|
|
|
|
private fun NostrEvent.optionalExpirationSeconds(): Long? {
|
|
val matches = tags.filter { it.firstOrNull() == EXPIRATION_TAG }
|
|
if (matches.isEmpty()) return null
|
|
if (matches.size != 1) return null
|
|
val tag = matches.single()
|
|
if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null
|
|
return tag[1]
|
|
.toULongOrNull()
|
|
?.takeIf { it <= Long.MAX_VALUE.toULong() }
|
|
?.toLong()
|
|
}
|
|
|
|
private fun NostrEvent.requiredMillisecondTimestamp(): Long? {
|
|
val matches = tags.filter { it.firstOrNull() == MILLISECOND_TIMESTAMP_TAG }
|
|
if (matches.size != 1) return null
|
|
val tag = matches.single()
|
|
if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null
|
|
return tag[1]
|
|
.toULongOrNull()
|
|
?.takeIf { it <= Long.MAX_VALUE.toULong() }
|
|
?.toLong()
|
|
}
|
|
}
|