mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 12:25:20 +00:00
fix nip-17 (#305)
* NIP-59 stuff * nip-17 ios to androing works, android to ios still wip * fix impl * ios compat * default relays for DMs * delivery ack works * delivery ack --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -7,14 +7,11 @@ import org.bouncycastle.crypto.params.ECPublicKeyParameters
|
||||
import org.bouncycastle.math.ec.ECPoint
|
||||
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
|
||||
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
|
||||
import org.bouncycastle.crypto.digests.SHA256Digest
|
||||
import org.bouncycastle.crypto.macs.HMac
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import com.google.crypto.tink.subtle.XChaCha20Poly1305
|
||||
import java.security.SecureRandom
|
||||
import java.security.MessageDigest
|
||||
import java.math.BigInteger
|
||||
@@ -26,6 +23,7 @@ import java.math.BigInteger
|
||||
object NostrCrypto {
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
// NIP-44 v2 only
|
||||
|
||||
// secp256k1 curve parameters
|
||||
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
|
||||
@@ -99,7 +97,7 @@ object NostrCrypto {
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||
|
||||
// Try to recover full public key point from x-only coordinate
|
||||
// Recover full public key point from x-only coordinate (prefer even y per BIP-340)
|
||||
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
|
||||
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
|
||||
|
||||
@@ -119,10 +117,38 @@ object NostrCrypto {
|
||||
maxOf(0, 32 - sharedSecretBytes.size),
|
||||
minOf(sharedSecretBytes.size, 32)
|
||||
)
|
||||
} else {
|
||||
// If BigInteger added a leading sign byte, keep the last 32 bytes
|
||||
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform ECDH with explicit parity choice for the x-only public key
|
||||
* If preferOddY is true, use the odd-y lift; otherwise even-y lift.
|
||||
*/
|
||||
private fun performECDHWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ByteArray {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
|
||||
val publicKeyParams = ECPublicKeyParameters(point, secp256k1Params)
|
||||
val agreement = ECDHBasicAgreement()
|
||||
agreement.init(privateKeyParams)
|
||||
val sharedSecret = agreement.calculateAgreement(publicKeyParams)
|
||||
val sharedSecretBytes = sharedSecret.toByteArray()
|
||||
val result = ByteArray(32)
|
||||
if (sharedSecretBytes.size <= 32) {
|
||||
System.arraycopy(sharedSecretBytes, maxOf(0, sharedSecretBytes.size - 32), result, maxOf(0, 32 - sharedSecretBytes.size), minOf(sharedSecretBytes.size, 32))
|
||||
} else {
|
||||
// If BigInteger added a leading sign byte, keep the last 32 bytes
|
||||
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover full EC point from x-only coordinate
|
||||
@@ -147,96 +173,138 @@ object NostrCrypto {
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverPublicKeyPointWithParity(xOnlyBytes: ByteArray, preferOddY: Boolean): ECPoint {
|
||||
require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" }
|
||||
val prefix: Byte = if (preferOddY) 0x03 else 0x02
|
||||
val compressedBytes = ByteArray(33)
|
||||
compressedBytes[0] = prefix
|
||||
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 key derivation using HKDF
|
||||
* Compute the ECDH shared point (uncompressed) with an explicit parity choice for the x-only public key
|
||||
*/
|
||||
private fun computeSharedPointWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ECPoint {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
|
||||
return point.multiply(privateKeyBigInt).normalize()
|
||||
}
|
||||
|
||||
private fun compressedPoint(point: ECPoint): ByteArray {
|
||||
val normalized = point.normalize()
|
||||
val x = normalized.xCoord.encoded
|
||||
val prefix: Byte = if (hasEvenY(normalized)) 0x02 else 0x03
|
||||
val out = ByteArray(33)
|
||||
out[0] = prefix
|
||||
System.arraycopy(x, 0, out, 1, 32)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 v2 key derivation using HKDF-SHA256
|
||||
* salt = empty, info = "nip44-v2", length = 32 bytes
|
||||
*/
|
||||
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
|
||||
val salt = "nip44-v2".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// HKDF-Extract
|
||||
val zeroSalt = ByteArray(0)
|
||||
val prk = hkdfExtract(zeroSalt, sharedSecret)
|
||||
return hkdfExpand(prk, info = "nip44-v2".toByteArray(Charsets.UTF_8), length = 32)
|
||||
}
|
||||
|
||||
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
|
||||
val hmac = HMac(SHA256Digest())
|
||||
hmac.init(KeyParameter(salt))
|
||||
hmac.update(sharedSecret, 0, sharedSecret.size)
|
||||
hmac.update(ikm, 0, ikm.size)
|
||||
val prk = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(prk, 0)
|
||||
|
||||
// HKDF-Expand (we need 32 bytes for AES-256)
|
||||
hmac.init(KeyParameter(prk))
|
||||
hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1
|
||||
val okm = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(okm, 0)
|
||||
|
||||
// Return first 32 bytes
|
||||
return okm.copyOf(32)
|
||||
return prk
|
||||
}
|
||||
|
||||
|
||||
private fun hkdfExpand(prk: ByteArray, info: ByteArray?, length: Int): ByteArray {
|
||||
val hmac = HMac(SHA256Digest())
|
||||
hmac.init(KeyParameter(prk))
|
||||
if (info != null && info.isNotEmpty()) {
|
||||
hmac.update(info, 0, info.size)
|
||||
}
|
||||
hmac.update(byteArrayOf(0x01), 0, 1)
|
||||
val t = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(t, 0)
|
||||
return t.copyOf(length)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 encryption using AES-256-GCM
|
||||
* NIP-44 v2 encryption using XChaCha20-Poly1305
|
||||
* Output format: "v2:" + base64url(nonce24 || ciphertext || tag)
|
||||
*/
|
||||
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String {
|
||||
fun encryptNIP44(
|
||||
plaintext: String,
|
||||
recipientPublicKeyHex: String,
|
||||
senderPrivateKeyHex: String
|
||||
): String {
|
||||
try {
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex)
|
||||
|
||||
// Derive encryption key
|
||||
val encryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Generate random nonce (12 bytes for GCM)
|
||||
val nonce = ByteArray(12)
|
||||
secureRandom.nextBytes(nonce)
|
||||
|
||||
// Encrypt using AES-256-GCM
|
||||
val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(encryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
|
||||
val ciphertext = cipher.doFinal(plaintextBytes)
|
||||
|
||||
// Combine nonce + ciphertext (includes auth tag)
|
||||
val result = ByteArray(nonce.size + ciphertext.size)
|
||||
System.arraycopy(nonce, 0, result, 0, nonce.size)
|
||||
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
|
||||
|
||||
// Base64 encode
|
||||
return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP)
|
||||
// Match iOS: derive HKDF input from the compressed shared point (33 bytes)
|
||||
val sharedPoint = computeSharedPointWithParity(senderPrivateKeyHex, recipientPublicKeyHex, preferOddY = false)
|
||||
val secretMaterial = compressedPoint(sharedPoint)
|
||||
val encryptionKey = deriveNIP44Key(secretMaterial)
|
||||
val aead = XChaCha20Poly1305(encryptionKey)
|
||||
val combined = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) // nonce||ct||tag
|
||||
val b64 = base64UrlNoPad(combined)
|
||||
android.util.Log.d("NostrCrypto", "NIP44 v2 encrypt: len=${b64.length}")
|
||||
return "v2:$b64"
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 encryption failed: ${e.message}", e)
|
||||
throw RuntimeException("NIP-44 v2 encryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* NIP-44 decryption using AES-256-GCM
|
||||
* NIP-44 v2 decryption using XChaCha20-Poly1305
|
||||
* Only accepts the exact "v2:" base64url format.
|
||||
* Tries both even/odd Y parities for x-only pubkeys.
|
||||
*/
|
||||
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
|
||||
try {
|
||||
// Decode base64
|
||||
val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP)
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
require(encryptedData.size >= 12 + 16) { "Ciphertext too short" }
|
||||
val nonce = encryptedData.copyOfRange(0, 12)
|
||||
val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size)
|
||||
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex)
|
||||
|
||||
// Derive decryption key
|
||||
val decryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Decrypt using AES-256-GCM
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(decryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
||||
val plaintextBytes = cipher.doFinal(ciphertextBytes)
|
||||
|
||||
return String(plaintextBytes, Charsets.UTF_8)
|
||||
require(ciphertext.startsWith("v2:")) { "Invalid NIP-44 version prefix" }
|
||||
val encoded = ciphertext.substring(3)
|
||||
val encryptedData = base64UrlDecode(encoded)
|
||||
?: throw IllegalArgumentException("Invalid base64url payload")
|
||||
|
||||
var lastError: Exception? = null
|
||||
// Try even-Y first, then odd-Y
|
||||
for (preferOdd in listOf(false, true)) {
|
||||
try {
|
||||
// Match iOS: derive HKDF input from the compressed shared point (33 bytes)
|
||||
val point = computeSharedPointWithParity(recipientPrivateKeyHex, senderPublicKeyHex, preferOddY = preferOdd)
|
||||
val secretMaterial = compressedPoint(point)
|
||||
val key = deriveNIP44Key(secretMaterial)
|
||||
val aead = XChaCha20Poly1305(key)
|
||||
val pt = aead.decrypt(encryptedData, null) // expects nonce||ct||tag
|
||||
return String(pt, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError ?: RuntimeException("NIP-44 v2 decryption failed")
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 decryption failed: ${e.message}", e)
|
||||
throw RuntimeException("NIP-44 v2 decryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun base64UrlNoPad(data: ByteArray): String {
|
||||
val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)
|
||||
return b64.replace('+', '-').replace('/', '_').replace("=", "")
|
||||
}
|
||||
|
||||
private fun base64UrlDecode(s: String): ByteArray? {
|
||||
var str = s.replace('-', '+').replace('_', '/')
|
||||
val pad = (4 - (str.length % 4)) % 4
|
||||
if (pad > 0) str += "=".repeat(pad)
|
||||
return try {
|
||||
android.util.Base64.decode(str, android.util.Base64.NO_WRAP)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +316,15 @@ object NostrCrypto {
|
||||
return (baseTimestamp + offset).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Random timestamp up to maxPastSeconds in the past (default 2 days)
|
||||
*/
|
||||
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
|
||||
return now - offset
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate secp256k1 private key
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user