diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index d613695448..a74614dda6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -21,37 +21,58 @@ package com.vitorpamplona.quartz.nip44Encryption import androidx.collection.LruCache -import com.vitorpamplona.quartz.nip01Core.core.toHexKey class SharedKeyCache { - // Keyed by the full (privateKey || pubKey) content, NOT by a 32-bit hashCode. - // A hashCode key silently collides: two distinct peers whose (priv, pub) bytes - // hash to the same Int would share a cache slot, so `get` could return one - // peer's conversation key for a message meant for the other — a silent - // wrong-key encrypt/decrypt. The polynomial hash that was used here collides - // independently of the private key, so an attacker could grind a pubkey that - // aliases a victim's contact. Keying on the raw bytes removes the collision. - private val sharedKeyCache = LruCache(200) + // Keyed by the full (privateKey, pubKey) content via [CacheKey], NOT by a bare + // 32-bit hashCode. Using a hashCode as the whole map key silently collides: + // two distinct peers whose (priv, pub) bytes hash to the same Int would share a + // slot, so `get` could return one peer's conversation key for a message meant + // for the other — a silent wrong-key encrypt/decrypt (and the polynomial hash + // that was used collides independently of the private key, so a pubkey aliasing + // a victim's contact is grindable). [CacheKey] keeps the cheap Int hash only as + // a bucket selector and disambiguates collisions with a full contentEquals, so + // it stays correct while avoiding the per-lookup allocation of a hex String key. + private val sharedKeyCache = LruCache(200) fun clearCache() { sharedKeyCache.evictAll() } - private fun cacheKey( - privateKey: ByteArray, - pubKey: ByteArray, - ): String = privateKey.toHexKey() + pubKey.toHexKey() - fun get( privateKey: ByteArray, pubKey: ByteArray, - ): ByteArray? = sharedKeyCache[cacheKey(privateKey, pubKey)] + ): ByteArray? = sharedKeyCache[CacheKey(privateKey, pubKey)] fun add( privateKey: ByteArray, pubKey: ByteArray, secret: ByteArray, ) { - sharedKeyCache.put(cacheKey(privateKey, pubKey), secret) + sharedKeyCache.put(CacheKey(privateKey, pubKey), secret) + } + + /** + * Content-addressed cache key holding the raw key references (no byte copy, no + * hex string). The precomputed [hash] is only a bucket selector — [equals] does + * the authoritative full-content comparison, so hash collisions can never return + * the wrong peer's secret. Callers must treat the passed arrays as immutable + * (the same value-type contract [com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair] + * relies on when used as a map key). + */ + private class CacheKey( + val privateKey: ByteArray, + val pubKey: ByteArray, + ) { + private val hash = privateKey.contentHashCode() * 31 + pubKey.contentHashCode() + + override fun hashCode(): Int = hash + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CacheKey) return false + return hash == other.hash && + privateKey.contentEquals(other.privateKey) && + pubKey.contentEquals(other.pubKey) + } } }