refactor(nip44): use a content-addressed key object for the shared-key cache

Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
This commit is contained in:
Claude
2026-07-31 20:13:59 +00:00
parent e94b8eaf3b
commit a59bc6d82d
@@ -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<String, ByteArray>(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<CacheKey, ByteArray>(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)
}
}
}