mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.** { *; }
|
||||
22
quartz/proguard-rules.pro
vendored
22
quartz/proguard-rules.pro
vendored
@@ -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.** { *; }
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user