feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation

Remove LazySodium/JNA/libsodium native dependencies and replace with
pure Kotlin implementations of ChaCha20, HChaCha20, XChaCha20,
Poly1305 MAC, and XChaCha20-Poly1305 AEAD. This eliminates platform-
specific native binaries while maintaining full NIP-44 compatibility.

- Add ChaCha20Core with RFC 8439 block function, IETF stream XOR,
  HChaCha20, and XChaCha20
- Add Poly1305 MAC (RFC 8439 §2.5)
- Add XChaCha20Poly1305 AEAD encrypt/decrypt
- Convert LibSodiumInstance from expect/actual to commonMain object
- Delete platform-specific LibSodiumInstance actuals (android/jvm/ios)
- Remove iOS native interop (cinterop, linker opts, .a libraries)
- Remove lazysodium-java, lazysodium-android, JNA from build deps
- Clean up ProGuard rules (remove JNA/LazySodium keep rules)
- Add RFC 8439 + libsodium test vectors for ChaCha20 and XChaCha20
- Update benchmark to use simplified ChaCha20 API

https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i
This commit is contained in:
Claude
2026-03-23 17:40:29 +00:00
parent bbb75afed9
commit d586a0dc25
15 changed files with 1031 additions and 558 deletions

View File

@@ -52,36 +52,16 @@ class ChaCha20Benchmark {
}
@Test
fun encryptLibSodium() {
fun encrypt() {
benchmarkRule.measureRepeated {
chaCha.encryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
chaCha.encrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
/*
Removed in the conversion to KMP
@Test
fun encryptNative() {
fun decrypt() {
benchmarkRule.measureRepeated {
chaCha.encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
*/
@Test
fun decryptLibSodium() {
benchmarkRule.measureRepeated {
chaCha.decryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
/*
Removed in the conversion to KMP
@Test
fun decryptNative() {
benchmarkRule.measureRepeated {
chaCha.decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
*/
}

View File

@@ -25,7 +25,6 @@ fragmentKtx = "1.8.9"
gms = "4.4.4"
jacksonModuleKotlin = "2.21.1"
javaKeyring = "1.0.4"
jna = "5.18.1"
jtorctl = "0.4.5.7"
junit = "4.13.2"
kchesslib = "1.0.5"
@@ -34,8 +33,6 @@ kotlinxCollectionsImmutable = "0.4.0"
kotlinxCoroutinesCore = "1.10.2"
kotlinxSerialization = "1.10.0"
languageId = "17.0.6"
lazysodiumAndroid = "5.2.0"
lazysodiumJava = "5.2.0"
lifecycleRuntimeKtx = "2.10.0"
lightcompressor-enhanced = "1.6.0"
markdown = "f92ef49c9d"
@@ -144,7 +141,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" }
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
@@ -153,8 +149,6 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }
lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" }
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }

View File

@@ -63,65 +63,11 @@ kotlin {
// project can be found here:
// https://developer.android.com/kotlin/multiplatform/migrate
val xcfName = "quartz-kmpKit"
val libsodiumPath = project.file("src/nativeInterop/libsodium")
val libsodiumHeaderFilesPath = project.file("$libsodiumPath/include/sodium")
// Generate target-specific Libsodium definition files for creating native bindings.
// Device (iosArm64) uses libsodium.a, simulator targets use libsodium-simulator.a.
val libsodiumDeviceDefFile =
project.layout.buildDirectory
.file("cinterop/Clibsodium-device.def")
.get()
.asFile
val libsodiumSimulatorDefFile =
project.layout.buildDirectory
.file("cinterop/Clibsodium-simulator.def")
.get()
.asFile
// This generates the Libsodium definition file, necessary for creating native bindings(a Kotlin API) for libsodium(for iOS).
val libsodiumDefFileGeneration =
tasks.register("GenerateSodiumCinteropFile") {
outputs.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile)
doLast {
libsodiumDeviceDefFile.parentFile.mkdirs()
libsodiumDeviceDefFile.writeText(
"package = Clibsodium\n" +
"staticLibraries = libsodium.a\n" +
"libraryPaths = ${libsodiumPath.absolutePath}/ios/lib\n",
)
libsodiumSimulatorDefFile.writeText(
"package = Clibsodium\n" +
"staticLibraries = libsodium-simulator.a\n" +
"libraryPaths = ${libsodiumPath.absolutePath}/ios-simulators/lib\n",
)
}
}
listOf(
iosArm64(),
iosSimulatorArm64(),
).forEach { target ->
val isSimulator = target.name != "iosArm64"
val defFile = if (isSimulator) libsodiumSimulatorDefFile else libsodiumDeviceDefFile
target.compilations.getByName("main") {
val clibsodium by cinterops.creating {
definitionFile = defFile
packageName = "Clibsodium"
headers(
"$libsodiumHeaderFilesPath/crypto_aead_xchacha20poly1305.h",
"$libsodiumHeaderFilesPath/crypto_core_hchacha20.h",
"$libsodiumHeaderFilesPath/crypto_stream_chacha20.h",
)
}
tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure {
dependsOn(libsodiumDefFileGeneration)
}
}
target.swiftPackageConfig(cinteropName = "swiftbridge") {
minIos = "17"
minMacos = "14"
@@ -139,9 +85,6 @@ kotlin {
}
iosArm64 {
binaries.all {
linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium")
}
binaries.framework {
baseName = xcfName
binaryOption("bundleId", "com.vitorpamplona.quartz")
@@ -149,9 +92,6 @@ kotlin {
}
iosSimulatorArm64 {
binaries.all {
linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator")
}
binaries.framework {
baseName = xcfName
binaryOption("bundleId", "com.vitorpamplona.quartz")
@@ -254,9 +194,6 @@ kotlin {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
// LibSodium for ChaCha encryption (NIP-44)
implementation(libs.lazysodium.java)
implementation(libs.jna)
}
}
@@ -279,9 +216,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
// LibSodium for ChaCha encryption (NIP-44)
implementation("com.goterl:lazysodium-android:5.2.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
}
}
@@ -293,9 +227,6 @@ kotlin {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
// LibSodium for ChaCha encryption (NIP-44) - Needed for host tests
implementation(libs.lazysodium.java)
implementation(libs.jna)
// SQLite bundled driver for Host tests
implementation(libs.androidx.sqlite.bundled.jvm)
@@ -315,9 +246,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
// LibSodium for ChaCha encryption (NIP-44)
implementation("com.goterl:lazysodium-android:5.2.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
}
}

View File

@@ -16,32 +16,10 @@
# preserve access to native classses
-keep class fr.acinq.secp256k1.** { *; }
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# libscrypt
-keep class com.lambdaworks.codec.** { *; }
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
-keep class com.sun.jna.CallbackReference { *; }
-keep class com.sun.jna.ptr.IntByReference { *; }
-keep class com.sun.jna.NativeLong { *; }
-keep class com.sun.jna.Structure { *; }
-keep class com.sun.jna.Structure$* { *; }
-keep class com.sun.jna.Native$ffi_callback { *; }
-keep class * implements com.sun.jna.Structure$* { *; }
-keep class * implements com.sun.jna.Native$* { *; }
-keep class com.sun.jna.Native {
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
private static java.lang.Class nativeType(java.lang.Class);
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
}
# JSON parsing
-keep class com.vitorpamplona.quartz.** { *; }

View File

@@ -30,33 +30,11 @@
# preserve access to native classses
-keep class fr.acinq.secp256k1.** { *; }
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# libscrypt
-keep class com.lambdaworks.codec.** { *; }
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
-keep class com.sun.jna.CallbackReference { *; }
-keep class com.sun.jna.ptr.IntByReference { *; }
-keep class com.sun.jna.NativeLong { *; }
-keep class com.sun.jna.Structure { *; }
-keep class com.sun.jna.Structure$* { *; }
-keep class com.sun.jna.Native$ffi_callback { *; }
-keep class * implements com.sun.jna.Structure$* { *; }
-keep class * implements com.sun.jna.Native$* { *; }
-keep class com.sun.jna.Native {
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
private static java.lang.Class nativeType(java.lang.Class);
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
}
# JSON parsing
-keep class com.vitorpamplona.quartz.** { *; }

View File

@@ -1,131 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
import com.goterl.lazysodium.LazySodium
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.Sodium
import com.goterl.lazysodium.SodiumAndroid
actual object LibSodiumInstance {
private val libSodium: Sodium =
try {
// If we are running in a host test, SodiumJava might be available.
// SodiumJava uses a ResourceLoader to find the dylib/so/dll in the jar.
Class
.forName("com.goterl.lazysodium.SodiumJava")
.getConstructor()
.newInstance() as Sodium
} catch (_: Exception) {
SodiumAndroid()
}
private val lazySodium: LazySodium =
if (libSodium is SodiumAndroid) {
LazySodiumAndroid(libSodium)
} else {
// this should only happen on test cases
val sodiumJava =
Class
.forName("com.goterl.lazysodium.SodiumJava")
Class
.forName("com.goterl.lazysodium.LazySodiumJava")
.getConstructor(sodiumJava)
.newInstance(libSodium) as LazySodium
}
actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
message: ByteArray,
nSec: ByteArray,
ciphertext: ByteArray,
ad: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean =
lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
message,
longArrayOf(message.size.toLong()),
nSec,
ciphertext,
ciphertext.size.toLong(),
ad,
ad.size.toLong(),
nPub,
k,
)
actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext: ByteArray,
message: ByteArray,
ad: ByteArray,
nSec: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean =
lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext,
longArrayOf(ciphertext.size.toLong()),
message,
message.size.toLong(),
ad,
ad.size.toLong(),
nSec,
nPub,
k,
)
actual fun cryptoStreamChaCha20IetfXor(
message: ByteArray,
nonce: ByteArray?,
key: ByteArray?,
): ByteArray {
val ciphertext = ByteArray(message.size)
lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key)
return ciphertext
}
// This function wasn't available in the bindings library. I had to move them here from C
actual fun cryptoStreamXChaCha20Xor(
messageBytes: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = ByteArray(messageBytes.size)
val k2 = ByteArray(32)
val nonceChaCha = nonce.drop(16).toByteArray()
assert(nonceChaCha.size == 8)
libSodium.crypto_core_hchacha20(k2, nonce, key, null)
val resultCode =
libSodium.crypto_stream_chacha20_xor_ic(
cipher,
messageBytes,
messageBytes.size.toLong(),
nonceChaCha,
0,
k2,
)
return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message")
}
}

View File

@@ -20,55 +20,16 @@
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
import com.vitorpamplona.quartz.utils.LibSodiumInstance
/**
* Encapsulates the ChaCha20 options. LibSodium is faster on real hardware: 851ns vs 2,535ns encrypt/decrypt times
*/
class ChaCha20 {
fun encrypt(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
) = encryptLibSodium(message, nonce, key)
) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0)
fun decrypt(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
) = decryptLibSodium(message, nonce, key)
/*
fun encryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
fun decryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
*/
fun encryptLibSodium(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key)
fun decryptLibSodium(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key)
) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0)
}

View File

@@ -0,0 +1,266 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
/**
* Pure Kotlin implementation of ChaCha20, HChaCha20, and XChaCha20.
*
* All functions are stateless and thread-safe — they use only local variables
* and produce no side effects beyond their return values.
*
* References:
* - RFC 8439: ChaCha20 and Poly1305
* - draft-irtf-cfrg-xchacha: XChaCha20 (HChaCha20 + ChaCha20)
*/
object ChaCha20Core {
// "expand 32-byte k" as little-endian integers
private const val SIGMA0 = 0x61707865.toInt()
private const val SIGMA1 = 0x3320646e.toInt()
private const val SIGMA2 = 0x79622d32.toInt()
private const val SIGMA3 = 0x6b206574.toInt()
/**
* ChaCha20 quarter round (RFC 8439 §2.1).
* Operates in-place on the 16-word state.
*/
private fun quarterRound(
state: IntArray,
a: Int,
b: Int,
c: Int,
d: Int,
) {
state[a] += state[b]; state[d] = (state[d] xor state[a]).rotateLeft(16)
state[c] += state[d]; state[b] = (state[b] xor state[c]).rotateLeft(12)
state[a] += state[b]; state[d] = (state[d] xor state[a]).rotateLeft(8)
state[c] += state[d]; state[b] = (state[b] xor state[c]).rotateLeft(7)
}
/**
* Perform 20 rounds (10 double-rounds) of ChaCha on the state.
*/
private fun chaCha20Rounds(state: IntArray) {
repeat(10) {
// Column rounds
quarterRound(state, 0, 4, 8, 12)
quarterRound(state, 1, 5, 9, 13)
quarterRound(state, 2, 6, 10, 14)
quarterRound(state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(state, 0, 5, 10, 15)
quarterRound(state, 1, 6, 11, 12)
quarterRound(state, 2, 7, 8, 13)
quarterRound(state, 3, 4, 9, 14)
}
}
/**
* Initialize the ChaCha20 state matrix from key, counter, and nonce.
* Layout (RFC 8439 §2.3):
* cccccccc cccccccc cccccccc cccccccc
* kkkkkkkk kkkkkkkk kkkkkkkk kkkkkkkk
* kkkkkkkk kkkkkkkk kkkkkkkk kkkkkkkk
* bbbbbbbb nnnnnnnn nnnnnnnn nnnnnnnn
*/
private fun initState(
key: ByteArray,
counter: Int,
nonce: ByteArray,
): IntArray {
val state = IntArray(16)
state[0] = SIGMA0
state[1] = SIGMA1
state[2] = SIGMA2
state[3] = SIGMA3
state[4] = key.littleEndianToInt(0)
state[5] = key.littleEndianToInt(4)
state[6] = key.littleEndianToInt(8)
state[7] = key.littleEndianToInt(12)
state[8] = key.littleEndianToInt(16)
state[9] = key.littleEndianToInt(20)
state[10] = key.littleEndianToInt(24)
state[11] = key.littleEndianToInt(28)
state[12] = counter
state[13] = nonce.littleEndianToInt(0)
state[14] = nonce.littleEndianToInt(4)
state[15] = nonce.littleEndianToInt(8)
return state
}
/**
* ChaCha20 block function (RFC 8439 §2.3).
* Produces a 64-byte keystream block.
*
* @param key 32-byte key
* @param counter block counter
* @param nonce 12-byte nonce (IETF variant)
* @return 64-byte keystream block
*/
fun chaCha20Block(
key: ByteArray,
counter: Int,
nonce: ByteArray,
): ByteArray {
val initial = initState(key, counter, nonce)
val working = initial.copyOf()
chaCha20Rounds(working)
// Add initial state to working state
for (i in 0..15) {
working[i] += initial[i]
}
// Serialize to little-endian bytes
val output = ByteArray(64)
for (i in 0..15) {
working[i].intToLittleEndian(output, i * 4)
}
return output
}
/**
* ChaCha20 IETF stream cipher XOR (RFC 8439 §2.4).
* XORs the message with the ChaCha20 keystream.
*
* @param message plaintext or ciphertext
* @param key 32-byte key
* @param nonce 12-byte nonce
* @param counter initial block counter (0 for stream cipher, 1 for AEAD)
* @return XOR'd output (same length as message)
*/
fun chaCha20Xor(
message: ByteArray,
key: ByteArray,
nonce: ByteArray,
counter: Int = 0,
): ByteArray {
val output = ByteArray(message.size)
val fullBlocks = message.size / 64
val remainder = message.size % 64
for (i in 0 until fullBlocks) {
val block = chaCha20Block(key, counter + i, nonce)
val offset = i * 64
for (j in 0..63) {
output[offset + j] = (message[offset + j].toInt() xor block[j].toInt()).toByte()
}
}
if (remainder > 0) {
val block = chaCha20Block(key, counter + fullBlocks, nonce)
val offset = fullBlocks * 64
for (j in 0 until remainder) {
output[offset + j] = (message[offset + j].toInt() xor block[j].toInt()).toByte()
}
}
return output
}
/**
* HChaCha20 (draft-irtf-cfrg-xchacha §2.2).
* Derives a 32-byte subkey from a 32-byte key and 16-byte input.
* Used as the first step of XChaCha20.
*
* @param key 32-byte key
* @param input 16-byte input (first 16 bytes of the 24-byte XChaCha20 nonce)
* @return 32-byte subkey
*/
fun hChaCha20(
key: ByteArray,
input: ByteArray,
): ByteArray {
val state = IntArray(16)
state[0] = SIGMA0
state[1] = SIGMA1
state[2] = SIGMA2
state[3] = SIGMA3
state[4] = key.littleEndianToInt(0)
state[5] = key.littleEndianToInt(4)
state[6] = key.littleEndianToInt(8)
state[7] = key.littleEndianToInt(12)
state[8] = key.littleEndianToInt(16)
state[9] = key.littleEndianToInt(20)
state[10] = key.littleEndianToInt(24)
state[11] = key.littleEndianToInt(28)
state[12] = input.littleEndianToInt(0)
state[13] = input.littleEndianToInt(4)
state[14] = input.littleEndianToInt(8)
state[15] = input.littleEndianToInt(12)
chaCha20Rounds(state)
// Output words 0-3 and 12-15 (skip the key material in 4-11)
val output = ByteArray(32)
state[0].intToLittleEndian(output, 0)
state[1].intToLittleEndian(output, 4)
state[2].intToLittleEndian(output, 8)
state[3].intToLittleEndian(output, 12)
state[12].intToLittleEndian(output, 16)
state[13].intToLittleEndian(output, 20)
state[14].intToLittleEndian(output, 24)
state[15].intToLittleEndian(output, 28)
return output
}
/**
* XChaCha20 stream cipher XOR (draft-irtf-cfrg-xchacha §2.3).
* Uses HChaCha20 to derive a subkey, then applies ChaCha20 with the remaining nonce bytes.
*
* @param message plaintext or ciphertext
* @param nonce 24-byte nonce
* @param key 32-byte key
* @return XOR'd output (same length as message)
*/
fun xChaCha20Xor(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
// Step 1: Derive subkey using first 16 bytes of nonce
val subKey = hChaCha20(key, nonce.copyOfRange(0, 16))
// Step 2: Build 12-byte subnonce: 4 zero bytes + last 8 bytes of nonce
val subNonce = ByteArray(12)
nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24)
// Step 3: Encrypt with ChaCha20 using subkey, counter=0
return chaCha20Xor(message, subKey, subNonce, counter = 0)
}
}
// --- Little-endian conversion helpers ---
private fun ByteArray.littleEndianToInt(offset: Int): Int =
(this[offset].toInt() and 0xFF) or
((this[offset + 1].toInt() and 0xFF) shl 8) or
((this[offset + 2].toInt() and 0xFF) shl 16) or
((this[offset + 3].toInt() and 0xFF) shl 24)
private fun Int.intToLittleEndian(
output: ByteArray,
offset: Int,
) {
output[offset] = (this and 0xFF).toByte()
output[offset + 1] = (this ushr 8 and 0xFF).toByte()
output[offset + 2] = (this ushr 16 and 0xFF).toByte()
output[offset + 3] = (this ushr 24 and 0xFF).toByte()
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
/**
* Pure Kotlin implementation of Poly1305 one-time authenticator (RFC 8439 §2.5).
*
* Uses 130-bit arithmetic with 5 limbs of 26 bits each, following the approach
* from D.J. Bernstein's reference implementation. All functions are stateless
* and thread-safe.
*
* The 32-byte key is split into:
* - r (first 16 bytes): clamped per spec, used as the multiplier
* - s (last 16 bytes): added at the end
*/
object Poly1305 {
/**
* Compute a 16-byte Poly1305 MAC.
*
* @param message the data to authenticate
* @param key 32-byte one-time key (r || s)
* @return 16-byte authentication tag
*/
fun mac(
message: ByteArray,
key: ByteArray,
): ByteArray {
// Parse and clamp r from first 16 bytes of key
val r0 = (key.leToLong(0)) and 0x3ffffff
val r1 = (key.leToLong(3) ushr 2) and 0x3ffff03
val r2 = (key.leToLong(6) ushr 4) and 0x3ffc0ff
val r3 = (key.leToLong(9) ushr 6) and 0x3f03fff
val r4 = (key.leToLong(12) ushr 8) and 0x00fffff
// Precompute 5*r for the reduction step
val s1 = r1 * 5
val s2 = r2 * 5
val s3 = r3 * 5
val s4 = r4 * 5
// Accumulator: 5 limbs of 26 bits
var h0 = 0L
var h1 = 0L
var h2 = 0L
var h3 = 0L
var h4 = 0L
val fullBlocks = message.size / 16
val remainder = message.size % 16
// Process full 16-byte blocks
for (i in 0 until fullBlocks) {
val offset = i * 16
h0 += (message.leToLong(offset)) and 0x3ffffff
h1 += (message.leToLong(offset + 3) ushr 2) and 0x3ffffff
h2 += (message.leToLong(offset + 6) ushr 4) and 0x3ffffff
h3 += (message.leToLong(offset + 9) ushr 6) and 0x3ffffff
h4 += (message.leToLong(offset + 12) ushr 8) or (1L shl 24) // hibit = 1
// Multiply and reduce
val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1
val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2
val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3
val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4
val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0
// Partial reduction mod 2^130 - 5
var c: Long
c = d0 ushr 26; h0 = d0 and 0x3ffffff; h1 = d1 + c
c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 = d2 + c
c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 = d3 + c
c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 = d4 + c
c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5
c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c
}
// Process remaining bytes (if any)
if (remainder > 0) {
val block = ByteArray(17)
message.copyInto(block, 0, fullBlocks * 16, message.size)
block[remainder] = 1 // pad with 0x01
h0 += (block.leToLong(0)) and 0x3ffffff
h1 += (block.leToLong(3) ushr 2) and 0x3ffffff
h2 += (block.leToLong(6) ushr 4) and 0x3ffffff
h3 += (block.leToLong(9) ushr 6) and 0x3ffffff
h4 += (block.leToLong(12) ushr 8)
val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1
val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2
val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3
val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4
val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0
var c: Long
c = d0 ushr 26; h0 = d0 and 0x3ffffff; h1 = d1 + c
c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 = d2 + c
c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 = d3 + c
c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 = d4 + c
c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5
c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c
}
// Final reduction: fully carry and reduce mod 2^130 - 5
var c: Long
c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 += c
c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 += c
c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 += c
c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5
c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c
// Compute h + -(2^130 - 5) = h - 2^130 + 5
var g0 = h0 + 5; c = g0 ushr 26; g0 = g0 and 0x3ffffff
var g1 = h1 + c; c = g1 ushr 26; g1 = g1 and 0x3ffffff
var g2 = h2 + c; c = g2 ushr 26; g2 = g2 and 0x3ffffff
var g3 = h3 + c; c = g3 ushr 26; g3 = g3 and 0x3ffffff
var g4 = h4 + c - (1L shl 26)
// Select h if g4 is negative (bit 63 set), else select g
val mask = (g4 ushr 63) - 1 // 0 if h, all-ones if g
g0 = g0 and mask
g1 = g1 and mask
g2 = g2 and mask
g3 = g3 and mask
g4 = g4 and mask
val nmask = mask.inv()
h0 = (h0 and nmask) or g0
h1 = (h1 and nmask) or g1
h2 = (h2 and nmask) or g2
h3 = (h3 and nmask) or g3
h4 = (h4 and nmask) or g4
// Assemble h into 4 32-bit words
var f0 = ((h0) or (h1 shl 26)) and 0xffffffffL
var f1 = ((h1 ushr 6) or (h2 shl 20)) and 0xffffffffL
var f2 = ((h2 ushr 12) or (h3 shl 14)) and 0xffffffffL
var f3 = ((h3 ushr 18) or (h4 shl 8)) and 0xffffffffL
// Add s (second half of the key)
val s0 = key.leToUInt(16)
val s1Long = key.leToUInt(20)
val s2Long = key.leToUInt(24)
val s3Long = key.leToUInt(28)
f0 += s0; c = f0 ushr 32
f1 += s1Long + c; c = f1 ushr 32
f2 += s2Long + c; c = f2 ushr 32
f3 += s3Long + c
// Output as little-endian bytes
val tag = ByteArray(16)
tag[0] = (f0).toByte()
tag[1] = (f0 ushr 8).toByte()
tag[2] = (f0 ushr 16).toByte()
tag[3] = (f0 ushr 24).toByte()
tag[4] = (f1).toByte()
tag[5] = (f1 ushr 8).toByte()
tag[6] = (f1 ushr 16).toByte()
tag[7] = (f1 ushr 24).toByte()
tag[8] = (f2).toByte()
tag[9] = (f2 ushr 8).toByte()
tag[10] = (f2 ushr 16).toByte()
tag[11] = (f2 ushr 24).toByte()
tag[12] = (f3).toByte()
tag[13] = (f3 ushr 8).toByte()
tag[14] = (f3 ushr 16).toByte()
tag[15] = (f3 ushr 24).toByte()
return tag
}
}
/** Read 4 bytes as unsigned little-endian Long starting at [offset]. */
private fun ByteArray.leToUInt(offset: Int): Long =
(this[offset].toLong() and 0xFF) or
((this[offset + 1].toLong() and 0xFF) shl 8) or
((this[offset + 2].toLong() and 0xFF) shl 16) or
((this[offset + 3].toLong() and 0xFF) shl 24)
/** Read 4 bytes as unsigned little-endian Long starting at [offset]. Used for Poly1305 limb loading. */
private fun ByteArray.leToLong(offset: Int): Long =
(this[offset].toLong() and 0xFF) or
((this[offset + 1].toLong() and 0xFF) shl 8) or
((this[offset + 2].toLong() and 0xFF) shl 16) or
((this[offset + 3].toLong() and 0xFF) shl 24)

View File

@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
/**
* Pure Kotlin implementation of XChaCha20-Poly1305 AEAD (RFC 8439 §2.8 + XChaCha20 draft).
*
* All functions are stateless and thread-safe.
*
* Construction:
* 1. Derive subkey via HChaCha20(key, nonce[0..15])
* 2. Build 12-byte subnonce: 0x00000000 || nonce[16..23]
* 3. Generate Poly1305 OTK from ChaCha20 block 0
* 4. Encrypt with ChaCha20 starting at counter 1
* 5. Compute Poly1305 tag over: pad16(ad) || pad16(ciphertext) || len(ad) || len(ct)
*/
object XChaCha20Poly1305 {
private const val TAG_SIZE = 16
/**
* AEAD encrypt.
*
* @param plaintext message to encrypt
* @param ad associated data (authenticated but not encrypted)
* @param nonce 24-byte nonce
* @param key 32-byte key
* @return ciphertext || 16-byte tag
*/
fun encrypt(
plaintext: ByteArray,
ad: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
// Step 1-2: Derive subkey and subnonce
val subKey = ChaCha20Core.hChaCha20(key, nonce.copyOfRange(0, 16))
val subNonce = ByteArray(12)
nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24)
// Step 3: Generate Poly1305 one-time key from block 0
val polyKey = ChaCha20Core.chaCha20Block(subKey, 0, subNonce).copyOfRange(0, 32)
// Step 4: Encrypt plaintext with counter starting at 1
val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, subKey, subNonce, counter = 1)
// Step 5: Compute tag
val tag = computeTag(polyKey, ad, ciphertext)
// Step 6: Return ciphertext || tag
return ciphertext + tag
}
/**
* AEAD decrypt.
*
* @param ciphertextWithTag ciphertext || 16-byte tag
* @param ad associated data
* @param nonce 24-byte nonce
* @param key 32-byte key
* @return plaintext, or throws on authentication failure
*/
fun decrypt(
ciphertextWithTag: ByteArray,
ad: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
require(ciphertextWithTag.size >= TAG_SIZE) { "Ciphertext too short" }
val ctLen = ciphertextWithTag.size - TAG_SIZE
val ciphertext = ciphertextWithTag.copyOfRange(0, ctLen)
val receivedTag = ciphertextWithTag.copyOfRange(ctLen, ciphertextWithTag.size)
// Step 1-2: Derive subkey and subnonce
val subKey = ChaCha20Core.hChaCha20(key, nonce.copyOfRange(0, 16))
val subNonce = ByteArray(12)
nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24)
// Step 3: Generate Poly1305 one-time key
val polyKey = ChaCha20Core.chaCha20Block(subKey, 0, subNonce).copyOfRange(0, 32)
// Step 4: Verify tag
val expectedTag = computeTag(polyKey, ad, ciphertext)
check(constantTimeEquals(receivedTag, expectedTag)) { "Authentication failed" }
// Step 5: Decrypt
return ChaCha20Core.chaCha20Xor(ciphertext, subKey, subNonce, counter = 1)
}
/**
* Compute Poly1305 tag over the AEAD construction:
* pad16(ad) || pad16(ciphertext) || len(ad) as 8-byte LE || len(ct) as 8-byte LE
*/
private fun computeTag(
polyKey: ByteArray,
ad: ByteArray,
ciphertext: ByteArray,
): ByteArray {
val adPadLen = if (ad.size % 16 == 0) 0 else 16 - (ad.size % 16)
val ctPadLen = if (ciphertext.size % 16 == 0) 0 else 16 - (ciphertext.size % 16)
val macData = ByteArray(ad.size + adPadLen + ciphertext.size + ctPadLen + 16)
var offset = 0
// pad16(ad)
ad.copyInto(macData, offset)
offset += ad.size + adPadLen
// pad16(ciphertext)
ciphertext.copyInto(macData, offset)
offset += ciphertext.size + ctPadLen
// len(ad) as 8-byte little-endian
longToLittleEndian(ad.size.toLong(), macData, offset)
offset += 8
// len(ciphertext) as 8-byte little-endian
longToLittleEndian(ciphertext.size.toLong(), macData, offset)
return Poly1305.mac(macData, polyKey)
}
/** Constant-time comparison to prevent timing attacks. */
private fun constantTimeEquals(
a: ByteArray,
b: ByteArray,
): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
private fun longToLittleEndian(
value: Long,
output: ByteArray,
offset: Int,
) {
output[offset] = (value and 0xFF).toByte()
output[offset + 1] = (value ushr 8 and 0xFF).toByte()
output[offset + 2] = (value ushr 16 and 0xFF).toByte()
output[offset + 3] = (value ushr 24 and 0xFF).toByte()
output[offset + 4] = (value ushr 32 and 0xFF).toByte()
output[offset + 5] = (value ushr 40 and 0xFF).toByte()
output[offset + 6] = (value ushr 48 and 0xFF).toByte()
output[offset + 7] = (value ushr 56 and 0xFF).toByte()
}
}

View File

@@ -20,7 +20,14 @@
*/
package com.vitorpamplona.quartz.utils
expect object LibSodiumInstance {
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Core
import com.vitorpamplona.quartz.nip44Encryption.crypto.XChaCha20Poly1305
/**
* Pure Kotlin replacement for LazySodium/libsodium.
* All functions are stateless and thread-safe.
*/
object LibSodiumInstance {
fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
message: ByteArray,
nSec: ByteArray,
@@ -28,7 +35,14 @@ expect object LibSodiumInstance {
ad: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean
): Boolean =
try {
val plaintext = XChaCha20Poly1305.decrypt(ciphertext, ad, nPub, k)
plaintext.copyInto(message)
true
} catch (_: Exception) {
false
}
fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext: ByteArray,
@@ -37,18 +51,24 @@ expect object LibSodiumInstance {
nSec: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean
): Boolean =
try {
val result = XChaCha20Poly1305.encrypt(message, ad, nPub, k)
result.copyInto(ciphertext)
true
} catch (_: Exception) {
false
}
fun cryptoStreamChaCha20IetfXor(
message: ByteArray,
nonce: ByteArray?,
key: ByteArray?,
): ByteArray
): ByteArray = ChaCha20Core.chaCha20Xor(message, key!!, nonce!!, counter = 0)
// This function wasn't available in the bindings library. I had to move them here from C
fun cryptoStreamXChaCha20Xor(
messageBytes: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray
): ByteArray = ChaCha20Core.xChaCha20Xor(messageBytes, nonce, key)
}

View File

@@ -0,0 +1,253 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Test vectors from RFC 8439, XChaCha20 draft (draft-irtf-cfrg-xchacha-03),
* and libsodium test suite.
*/
class ChaCha20CoreTest {
private fun hex(s: String): ByteArray =
s.replace(" ", "").chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
// ===== RFC 8439 §2.3.2: ChaCha20 Block Function Test Vector =====
@Test
fun testChaCha20Block_RFC8439() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000090000004a00000000")
val counter = 1
val block = ChaCha20Core.chaCha20Block(key, counter, nonce)
val expected =
hex(
"10f1e7e4d13b5915500fdd1fa32071c4" +
"c7d1f4c733c068030422aa9ac3d46c4e" +
"d2826446079faa0914c2d705d98b02a2" +
"b5129cd1de164eb9cbd083e8a2503c4e",
)
assertContentEquals(expected, block)
}
// ===== RFC 8439 §2.4.2: ChaCha20 Encryption Test Vector =====
@Test
fun testChaCha20Encrypt_RFC8439() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000000000004a00000000")
val counter = 1
val plaintext =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
.encodeToByteArray()
val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter)
val expected =
hex(
"6e2e359a2568f98041ba0728dd0d6981" +
"e97e7aec1d4360c20a27afccfd9fae0b" +
"f91b65c5524733ab8f593dabcd62b357" +
"1639d624e65152ab8f530c359f0861d8" +
"07ca0dbf500d6a6156a38e088a22b65e" +
"52bc514d16ccf806818ce91ab7793736" +
"5af90bbf74a35be6b40b8eedf2785e42" +
"874d",
)
assertContentEquals(expected, ciphertext)
}
// ===== RFC 8439 §2.5.2: Poly1305 Test Vector =====
@Test
fun testPoly1305_RFC8439() {
val key = hex("85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b")
val message =
"Cryptographic Forum Research Group".encodeToByteArray()
val tag = Poly1305.mac(message, key)
val expected = hex("a8061dc1305136c6c22b8baf0c0127a9")
assertContentEquals(expected, tag)
}
// ===== RFC 8439 §2.8.2: AEAD ChaCha20-Poly1305 Test Vector =====
// Adapted for XChaCha20 via the XChaCha20 draft test vector
@Test
fun testPoly1305KeyGeneration_RFC8439() {
// RFC 8439 §2.6.2 Poly1305 Key Generation
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("000000000001020304050607")
val block = ChaCha20Core.chaCha20Block(key, 0, nonce)
val polyKey = block.copyOfRange(0, 32)
val expected = hex("8ad5a08b905f81cc815040274ab29471a833b637e3fd0da508dbb8e2fdd1a646")
assertContentEquals(expected, polyKey)
}
// ===== XChaCha20 draft §2.2.1: HChaCha20 Test Vector =====
@Test
fun testHChaCha20_Draft() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val input = hex("000000090000004a0000000031415927")
val subKey = ChaCha20Core.hChaCha20(key, input)
val expected = hex("82413b4227b27bfed30e42508a877d73a0f9e4d58a74a853c12ec41326d3ecdc")
assertContentEquals(expected, subKey)
}
// ===== XChaCha20 draft §A.3.1: XChaCha20-Poly1305 AEAD Test Vector =====
@Test
fun testXChaCha20Poly1305Encrypt_Draft() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val plaintext =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
.encodeToByteArray()
val result = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
// Ciphertext portion
val expectedCiphertext =
hex(
"bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb" +
"731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452" +
"2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9" +
"21f9664c97637da9768812f615c68b13b52e",
)
// Tag portion
val expectedTag = hex("c0875924c1c7987947deafd8780acf49")
val ciphertext = result.copyOfRange(0, result.size - 16)
val tag = result.copyOfRange(result.size - 16, result.size)
assertContentEquals(expectedCiphertext, ciphertext)
assertContentEquals(expectedTag, tag)
}
@Test
fun testXChaCha20Poly1305Decrypt_Draft() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val ciphertextWithTag =
hex(
"bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb" +
"731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452" +
"2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9" +
"21f9664c97637da9768812f615c68b13b52e" +
"c0875924c1c7987947deafd8780acf49",
)
val plaintext = XChaCha20Poly1305.decrypt(ciphertextWithTag, ad, nonce, key)
val expected =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
assertEquals(expected, plaintext.decodeToString())
}
@Test
fun testXChaCha20Poly1305_TamperedCiphertext() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val plaintext = "Test message".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
// Flip a bit in the ciphertext
encrypted[0] = (encrypted[0].toInt() xor 1).toByte()
assertFailsWith<IllegalStateException> {
XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
}
}
@Test
fun testXChaCha20Poly1305_RoundTrip() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedfacedeadbeef")
val plaintext = "Hello, Nostr! This is a round-trip test.".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyPlaintext() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedface")
val plaintext = ByteArray(0)
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
assertEquals(16, encrypted.size) // Just the tag
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyAD() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = ByteArray(0)
val plaintext = "No additional data".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
// ===== ChaCha20 stream cipher (counter=0) for NIP-44v2 compatibility =====
@Test
fun testChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000090000004a00000000")
val plaintext = "Symmetric cipher test".encodeToByteArray()
val encrypted = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter = 0)
val decrypted = ChaCha20Core.chaCha20Xor(encrypted, key, nonce, counter = 0)
assertContentEquals(plaintext, decrypted)
}
// ===== XChaCha20 stream cipher symmetry =====
@Test
fun testXChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val plaintext = "XChaCha20 symmetric test".encodeToByteArray()
val encrypted = ChaCha20Core.xChaCha20Xor(plaintext, nonce, key)
val decrypted = ChaCha20Core.xChaCha20Xor(encrypted, nonce, key)
assertContentEquals(plaintext, decrypted)
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
/** Test vectors from libsodium xchacha20.c */
class XChaCha20Test {
private fun hex(s: String): ByteArray =
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// HChaCha20 test vectors from libsodium
data class HChaCha20TV(val key: String, val input: String, val expected: String)
private val hChaCha20Vectors = listOf(
HChaCha20TV(
"24f11cce8a1b3d61e441561a696c1c1b7e173d084fd4812425435a8896a013dc",
"d9660c5900ae19ddad28d6e06e45fe5e",
"5966b3eec3bff1189f831f06afe4d4e3be97fa9235ec8c20d08acfbbb4e851e3",
),
HChaCha20TV(
"80a5f6272031e18bb9bcd84f3385da65e7731b7039f13f5e3d475364cd4d42f7",
"c0eccc384b44c88e92c57eb2d5ca4dfa",
"6ed11741f724009a640a44fce7320954c46e18e0d7ae063bdbc8d7cf372709df",
),
HChaCha20TV(
"cb1fc686c0eec11a89438b6f4013bf110e7171dace3297f3a657a309b3199629",
"fcd49b93e5f8f299227e64d40dc864a3",
"84b7e96937a1a0a406bb7162eeaad34308d49de60fd2f7ec9dc6a79cbab2ca34",
),
HChaCha20TV(
"6640f4d80af5496ca1bc2cfff1fefbe99638dbceaabd7d0ade118999d45f053d",
"31f59ceeeafdbfe8cae7914caeba90d6",
"9af4697d2f5574a44834a2c2ae1a0505af9f5d869dbe381a994a18eb374c36a0",
),
HChaCha20TV(
"0693ff36d971225a44ac92c092c60b399e672e4cc5aafd5e31426f123787ac27",
"3a6293da061da405db45be1731d5fc4d",
"f87b38609142c01095bfc425573bb3c698f9ae866b7e4216840b9c4caf3b0865",
),
)
@Test
fun testHChaCha20_LibsodiumVectors() {
for ((i, tv) in hChaCha20Vectors.withIndex()) {
val result = ChaCha20Core.hChaCha20(hex(tv.key), hex(tv.input))
assertContentEquals(hex(tv.expected), result, "HChaCha20 vector $i failed")
}
}
// XChaCha20 stream test vectors from libsodium
data class XChaCha20TV(val key: String, val nonce: String, val expected: String)
private val xChaCha20Vectors = listOf(
XChaCha20TV(
"79c99798ac67300bbb2704c95c341e3245f3dcb21761b98e52ff45b24f304fc4",
"b33ffd3096479bcfbc9aee49417688a0a2554f8d95389419",
"c6e9758160083ac604ef90e712ce6e75d7797590744e0cf060f013739c",
),
XChaCha20TV(
"ddf7784fee099612c40700862189d0397fcc4cc4b3cc02b5456b3a97d1186173",
"a9a04491e7bf00c3ca91ac7c2d38a777d88993a7047dfcc4",
"2f289d371f6f0abc3cb60d11d9b7b29adf6bc5ad843e8493e928448d",
),
XChaCha20TV(
"3d12800e7b014e88d68a73f0a95b04b435719936feba60473f02a9e61ae60682",
"56bed2599eac99fb27ebf4ffcb770a64772dec4d5849ea2d",
"a2c3c1406f33c054a92760a8e0666b84f84fa3a618f0",
),
XChaCha20TV(
"eadc0e27f77113b5241f8ca9d6f9a5e7f09eee68d8a5cf30700563bf01060b4e",
"a171a4ef3fde7c4794c5b86170dc5a099b478f1b852f7b64",
"23839f61795c3cdbcee2c749a92543baeeea3cbb721402aa42e6cae140447575f2916c5d71108e3b13357eaf86f060cb",
),
XChaCha20TV(
"91319c9545c7c804ba6b712e22294c386fe31c4ff3d278827637b959d3dbaab2",
"410e854b2a911f174aaf1a56540fc3855851f41c65967a4e",
"cbe7d24177119b7fdfa8b06ee04dade4256ba7d35ffda6b89f014e479faef6",
),
)
@Test
fun testXChaCha20Xor_LibsodiumVectors() {
for ((i, tv) in xChaCha20Vectors.withIndex()) {
// XOR zeros with keystream to get the expected keystream output
val zeros = ByteArray(hex(tv.expected).size)
val result = ChaCha20Core.xChaCha20Xor(zeros, hex(tv.nonce), hex(tv.key))
assertContentEquals(hex(tv.expected), result, "XChaCha20 vector $i failed")
}
}
}

View File

@@ -1,130 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
import kotlinx.cinterop.CValuesRef
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.UByteVar
import kotlinx.cinterop.refTo
import kotlin.experimental.ExperimentalNativeApi
actual object LibSodiumInstance {
@OptIn(ExperimentalForeignApi::class)
actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
message: ByteArray,
nSec: ByteArray,
ciphertext: ByteArray,
ad: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean {
val returnCode =
Clibsodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
m = message.uRefTo(0),
mlen_p = ulongArrayOf(message.size.toULong()).refTo(0),
nsec = nSec.uRefTo(0),
c = ciphertext.uRefTo(0),
clen = ciphertext.size.toULong(),
ad = ad.uRefTo(0),
adlen = ad.size.toULong(),
npub = nPub.uRefTo(0),
k = k.uRefTo(0),
)
return returnCode == 0
}
@OptIn(ExperimentalForeignApi::class)
actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext: ByteArray,
message: ByteArray,
ad: ByteArray,
nSec: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean {
val retCode =
Clibsodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
c = ciphertext.uRefTo(0),
clen_p = ulongArrayOf(ciphertext.size.toULong()).refTo(0),
m = message.uRefTo(0),
mlen = message.size.toULong(),
ad = ad.uRefTo(0),
adlen = ad.size.toULong(),
nsec = nSec.uRefTo(0),
npub = nPub.uRefTo(0),
k = k.uRefTo(0),
)
return retCode == 0
}
@OptIn(ExperimentalForeignApi::class)
actual fun cryptoStreamChaCha20IetfXor(
message: ByteArray,
nonce: ByteArray?,
key: ByteArray?,
): ByteArray {
val ciphertext = ByteArray(message.size)
Clibsodium.crypto_stream_chacha20_ietf_xor(
c = ciphertext.uRefTo(0),
m = message.uRefTo(0),
mlen = message.size.toULong(),
n = nonce?.uRefTo(0),
k = key?.uRefTo(0),
)
return ciphertext
}
@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class)
actual fun cryptoStreamXChaCha20Xor(
messageBytes: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = ByteArray(messageBytes.size)
val k2 = ByteArray(32)
val nonceChaCha = nonce.drop(16).toByteArray()
assert(nonceChaCha.size == 8)
Clibsodium.crypto_core_hchacha20(
out = k2.uRefTo(0),
nonce.uRefTo(0),
k = key.uRefTo(0),
c = null,
)
val resultCode =
Clibsodium.crypto_stream_chacha20_xor_ic(
c = cipher.uRefTo(0),
m = messageBytes.uRefTo(0),
mlen = messageBytes.size.toULong(),
n = nonceChaCha.uRefTo(0),
ic = 0L.toULong(),
k = k2.uRefTo(0),
)
return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message")
}
}
@OptIn(ExperimentalForeignApi::class)
@Suppress("UNCHECKED_CAST")
private fun ByteArray.uRefTo(index: Int) = refTo(index) as CValuesRef<UByteVar>

View File

@@ -1,104 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
import com.goterl.lazysodium.LazySodiumJava
import com.goterl.lazysodium.SodiumJava
actual object LibSodiumInstance {
private val libSodium = SodiumJava()
private val lazySodium = LazySodiumJava(libSodium)
actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
message: ByteArray,
nSec: ByteArray,
ciphertext: ByteArray,
ad: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean =
lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
message,
longArrayOf(message.size.toLong()),
nSec,
ciphertext,
ciphertext.size.toLong(),
ad,
ad.size.toLong(),
nPub,
k,
)
actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext: ByteArray,
message: ByteArray,
ad: ByteArray,
nSec: ByteArray,
nPub: ByteArray,
k: ByteArray,
): Boolean =
lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
ciphertext,
longArrayOf(ciphertext.size.toLong()),
message,
message.size.toLong(),
ad,
ad.size.toLong(),
nSec,
nPub,
k,
)
actual fun cryptoStreamChaCha20IetfXor(
message: ByteArray,
nonce: ByteArray?,
key: ByteArray?,
): ByteArray {
val ciphertext = ByteArray(message.size)
lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key)
return ciphertext
}
actual fun cryptoStreamXChaCha20Xor(
messageBytes: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = ByteArray(messageBytes.size)
val k2 = ByteArray(32)
val nonceChaCha = nonce.drop(16).toByteArray()
assert(nonceChaCha.size == 8)
libSodium.crypto_core_hchacha20(k2, nonce, key, null)
val resultCode =
libSodium.crypto_stream_chacha20_xor_ic(
cipher,
messageBytes,
messageBytes.size.toLong(),
nonceChaCha,
0,
k2,
)
return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message")
}
}